QUIC Client Guide

View Source

This guide covers connecting to QUIC servers and using client features.

Quick Start

%% Start the QUIC application
application:ensure_all_started(quic).

%% Connect to a server
{ok, Conn} = quic:connect("example.com", 443, #{
    alpn => [<<"h3">>],
    verify => false  % For testing only!
}, self()).

%% Wait for connection
receive
    {quic, Conn, {connected, Info}} ->
        io:format("Connected! ALPN: ~p~n", [maps:get(alpn_protocol, Info)])
end.

%% Open a stream and send data
{ok, StreamId} = quic:open_stream(Conn),
ok = quic:send_data(Conn, StreamId, <<"Hello, QUIC!">>, true).

%% Receive response
receive
    {quic, Conn, {stream_data, StreamId, Data, _Fin}} ->
        io:format("Received: ~p~n", [Data])
end.

%% Close connection
quic:close(Conn, normal).

Connection Options

TLS Options

OptionTypeDefaultDescription
alpn[binary()][<<"h3">>]ALPN protocols to offer
verifybooleantrueVerify server certificate (verify_peer / verify_none also accepted)
server_namebinaryHostName checked against the certificate and sent as SNI; an IP address is checked but not sent
certbinary-Client certificate (for mTLS)
keyterm-Client private key (for mTLS), decoded; one that cannot sign for cert returns {error, {invalid_client_key, _}}
groups[atom()][x25519]Key-exchange groups in preference order (x25519, secp256r1, secp384r1, x25519mlkem768). The head gets a key_share; the rest are HelloRetryRequest-eligible. x25519mlkem768 is the post-quantum hybrid group (needs OTP 28.1+); naming an unsupported group returns {error, {unsupported_group, _}}.
signature_algs[atom()]historical listAdvertised signature schemes (ecdsa_secp256r1_sha256, ecdsa_secp384r1_sha384, rsa_pss_rsae_sha256|384|512, ed25519, rsa_pkcs1_sha256).
external_psktuple-TLS 1.3 external PSK; see PSK.md.

The connected event's Info map reports negotiated_group and negotiated_scheme for the chosen group and the server's CertificateVerify scheme.

Mixed-group fleet example: a client that prefers x25519 but can also speak NIST curves offers all three; a server pinned to secp256r1 triggers a HelloRetryRequest and the client retries transparently.

{ok, Conn} = quic:connect(Host, Port, #{
    verify => false,
    groups => [x25519, secp256r1, secp384r1]
}, self()).

Post-quantum example: prefer the hybrid ML-KEM group and fall back to classical x25519 for peers that do not offer it. The hybrid ClientHello is larger than one datagram and is chunked across Initial packets automatically.

{ok, Conn} = quic:connect(Host, Port, #{
    verify => false,
    groups => [x25519mlkem768, x25519]
}, self()).

Connection Options

OptionTypeDefaultDescription
idle_timeoutinteger30000Idle timeout in ms; the handshake must also complete within it
max_datainteger786432Connection-level receive limit
max_stream_data_bidi_localinteger524288Receive limit for streams we open
max_stream_data_bidi_remoteinteger524288Receive limit for streams the peer opens
max_stream_data_uniinteger524288Receive limit for unidirectional streams
max_receive_windowinteger8388608Largest window the receive limits grow to; at least 16384
max_owner_queueinteger | infinity1000Receive credit waits, and DATAGRAMs are dropped, while the owner's mailbox holds more messages than this
max_streams_bidiinteger100Max bidirectional streams
max_streams_uniinteger100Max unidirectional streams

Datagram Options (RFC 9221)

OptionTypeDefaultDescription
max_datagram_frame_sizeinteger0Max datagram size (0 = disabled)

Socket Options

OptionTypeDefaultDescription
socketgen_udp:socket()-Pre-opened UDP socket
extra_socket_optslist()[]Options for socket creation

Address Resolution Options

OptionTypeDefaultDescription
happy_eyeballsbooleantrueRace IPv6/IPv4 for dual-stack hostnames (RFC 8305)
familyinet | inet6 | anyanyRestrict resolution to one address family
connection_attempt_delayinteger250Happy Eyeballs stagger between attempts (ms)
connect_timeoutinteger5000Overall Happy Eyeballs deadline (ms)

Advanced Options

OptionTypeDefaultDescription
keep_alive_intervalinteger/atomdisabledPING interval in ms (min 5000), or auto for half the idle timeout
pmtu_enabledbooleantrueEnable Path MTU Discovery
max_udp_payload_sizeinteger1472 (IPv4) / 1452 (IPv6)Largest UDP payload we advertise being willing to receive

Features

Stream Management

%% Open bidirectional stream
{ok, BidiStreamId} = quic:open_stream(Conn).

%% Open unidirectional stream (send-only)
{ok, UniStreamId} = quic:open_unidirectional_stream(Conn).

%% Send data (Fin=true closes the send side)
ok = quic:send_data(Conn, StreamId, <<"data">>, false),
ok = quic:send_data(Conn, StreamId, <<"more">>, true).  % Final

%% Send with timeout
case quic:send_data(Conn, StreamId, Data, true, 5000) of
    ok -> sent;
    {error, timeout} -> handle_timeout()
end.

%% Backpressure: the connection's send queue is full, nothing was written.
%% Let the connection drain and send the same piece again.
case quic:send_data(Conn, StreamId, Data, false) of
    ok -> sent;
    {error, send_queue_full} -> retry_later(Data)
end.

%% Reset a stream with error code
ok = quic:reset_stream(Conn, StreamId, 0).

%% Request peer to stop sending
ok = quic:stop_sending(Conn, StreamId, 0).

Stream Prioritization (RFC 9218)

%% Set stream priority
%% Urgency: 0-7 (0 = most urgent, default 3)
%% Incremental: true if data can be processed incrementally
ok = quic:set_stream_priority(Conn, StreamId, 0, false).

%% Get current priority
{ok, {Urgency, Incremental}} = quic:get_stream_priority(Conn, StreamId).

Stream Deadlines

%% Set a 5-second deadline on a stream
ok = quic:set_stream_deadline(Conn, StreamId, 5000).

%% Set deadline with custom action
ok = quic:set_stream_deadline(Conn, StreamId, 5000, #{
    action => notify,  % notify | reset | both
    error_code => 16#FF
}).

%% Check remaining time
{ok, {RemainingMs, Action}} = quic:get_stream_deadline(Conn, StreamId).

%% Cancel deadline
ok = quic:cancel_stream_deadline(Conn, StreamId).

%% Handle deadline expiration
receive
    {quic, Conn, {stream_deadline, StreamId}} ->
        handle_deadline_expired(StreamId)
end.

Unreliable Datagrams (RFC 9221)

%% Enable datagrams (both client and server must enable)
{ok, Conn} = quic:connect(Host, Port, #{
    max_datagram_frame_size => 65535  % Accept any size
}, self()).

%% Largest payload send_datagram/2 accepts now (0: not supported).
%% It can grow once PMTU discovery confirms a larger path MTU.
MaxSize = quic:datagram_max_size(Conn),
case MaxSize of
    0 -> io:format("Datagrams not supported~n");
    _ -> io:format("Largest datagram now: ~p~n", [MaxSize])
end.

%% Send a datagram (unreliable, not retransmitted)
case quic:send_datagram(Conn, <<"game_state">>) of
    ok -> sent;
    {error, datagrams_not_supported} -> not_supported;
    {error, datagram_too_large} -> too_big;             % Above the peer's limit
    {error, datagram_too_large_for_path} -> too_big_now; % Above the path MTU
    {error, congestion_limited} -> dropped  % Normal for datagrams
end.

%% Receive datagrams
receive
    {quic, Conn, {datagram, Data}} ->
        handle_datagram(Data)
end.

Datagrams that arrive while the owner's mailbox holds more than max_owner_queue messages are dropped; quic:datagram_stats/1 counts them in dropped_recv.

Connection Migration (RFC 9000 Section 9)

Connection migration allows a QUIC connection to survive network changes (e.g., WiFi to cellular, NAT rebinding) without reconnecting.

%% Trigger migration to a new local address
ok = quic:migrate(Conn).

%% With custom timeout (default: 5000ms)
ok = quic:migrate(Conn, #{timeout => 10000}).

%% Migration can fail if peer disabled it
case quic:migrate(Conn) of
    ok ->
        io:format("Migration initiated~n");
    {error, migration_disabled} ->
        io:format("Peer disabled active migration~n")
end.

Key concept: The server address stays the same.

Migration changes the client's local address, not the server's. The connection continues to the same server, just from a different local IP/port:

BEFORE: Client {192.168.1.10:54321} ───> Server {203.0.113.50:4433}

AFTER:  Client {10.0.0.5:62000} ───────> Server {203.0.113.50:4433}
               ▲                                (same server!)
               └── Only the client's address changed

What happens during migration:

  1. Pick fresh DCID - Client selects an unused Connection ID from the pool the server provided earlier (via NEW_CONNECTION_ID frames). This prevents an observer from linking the old and new paths together.

  2. Rebind local socket - Client closes old socket, opens new one on a different local port (simulating a network change like WiFi to cellular).

  3. Send PATH_CHALLENGE - Client sends a PATH_CHALLENGE frame to the same server address but from its new local address.

  4. Receive PATH_RESPONSE - Server echoes the challenge data back, proving it can reach the client's new address.

  5. Reset path state - Congestion control, RTT estimation, and PMTU discovery are reset (the new path may have different characteristics).

Why use a fresh Connection ID?

RFC 9000 Section 9.5 requires using a new CID to prevent path linkability:

Old path: Client:54321 -> Server:4433, DCID=<<10,20,30,...>>
New path: Client:62000 -> Server:4433, DCID=<<11,21,31,...>>

An observer cannot easily correlate these as the same connection.

Server-side detection:

The server automatically detects when a client sends from a new address:

  • NAT rebinding: Same IP, different port (e.g., NAT timeout)
  • Active migration: Different IP address (e.g., network change)

In both cases, the server validates the new path before accepting it:

Client (new addr)                    Server
       |                               |
       |------- Data packet ---------->|  (from new address)
       |                               |  detect_peer_address_change()
       |<------ PATH_CHALLENGE --------|  initiate_peer_path_validation()
       |------- PATH_RESPONSE -------->|
       |                               |  complete_migration()
       |<======= Connection OK =======>|  (new path active)

Disabling migration:

To prevent migration (e.g., for server-side load balancing):

%% Server advertises disable_active_migration in transport params
%% Client will receive {error, migration_disabled} if it tries to migrate

Socket Binding

%% Bind to a specific local IP using extra_socket_opts
{ok, Conn} = quic:connect(Host, Port, #{
    extra_socket_opts => [{ip, {192,168,1,10}}]
}, self()).

%% Use a pre-opened socket for full control
{ok, Sock} = gen_udp:open(0, [binary, inet, {ip, {192,168,1,10}}]),
{ok, Conn} = quic:connect(Host, Port, #{
    socket => Sock
}, self()).

%% Note: When using socket option, the connection does not own the socket.
%% You must close it yourself after the connection terminates.

IPv6 and Happy Eyeballs (RFC 8305)

Host may be a hostname, an IP-literal string (IPv4 or IPv6, optionally bracketed), or an inet:ip_address() tuple.

quic:connect("example.com", 443, #{}, self()).   % hostname (Happy Eyeballs)
quic:connect("[2606:4700::1111]", 443, #{}, self()).  % IPv6 literal
quic:connect({2606,17008,16#1000,0,0,0,0,1}, 443, #{}, self()).  % address tuple

When a hostname resolves to both IPv4 and IPv6 addresses, the addresses are raced IPv6-first and the first to complete its handshake is used. In that case connect/4 blocks until the first attempt connects (or all fail / time out), then returns {ok, Conn}; the owner still receives the {connected, Info} message. A single resolved address, an IP literal/tuple, or a pre-opened socket keeps the immediate, asynchronous return.

%% Force IPv6, or disable racing for the legacy IPv4-first resolver.
quic:connect("example.com", 443, #{family => inet6}, self()).
quic:connect("example.com", 443, #{happy_eyeballs => false}, self()).

With happy_eyeballs => false, a hostname resolves IPv4-first (then IPv6) unless family => inet6 is set. A hostname that fails to resolve returns {error, Reason} rather than connecting to a default address.

0-RTT Session Resumption

%% First connection - receive session ticket
receive
    {quic, Conn, {session_ticket, Ticket}} ->
        %% Store ticket for later use
        store_ticket(Host, Ticket)
end.

%% Later connection - use stored ticket
StoredTicket = get_ticket(Host),
{ok, Conn2} = quic:connect(Host, Port, #{
    session_ticket => StoredTicket,
    early_data => <<"request">>  % Sent with 0-RTT
}, self()).

Connection Information

%% Get peer address
{ok, {IP, Port}} = quic:peername(Conn).

%% Get local address
{ok, {LocalIP, LocalPort}} = quic:sockname(Conn).

%% Get peer certificate
{ok, CertDer} = quic:peercert(Conn).

%% Get current MTU
{ok, MTU} = quic:get_mtu(Conn).

%% Get connection statistics
{ok, Stats} = quic:get_stats(Conn).
%% Stats = #{
%%     packets_sent => 150,
%%     packets_received => 148,
%%     data_sent => 50000,
%%     data_received => 45000,
%%     ack_sent => 42,
%%     retransmits => 0,
%%     batch_flushes => 12,
%%     packets_coalesced => 30,
%%     gso_flushes => 0,
%%     send_batch_pending => 0
%% }

Backpressure and Congestion

%% Check send queue status for backpressure
{ok, Info} = quic:get_send_queue_info(Conn).
%% Info = #{
%%     bytes => 5000,        % Bytes queued
%%     cwnd => 14720,        % Congestion window
%%     in_flight => 10000,   % Unacked bytes
%%     in_recovery => false, % In loss recovery?
%%     congested => false    % Should apply backpressure?
%% }

case maps:get(congested, Info) of
    true -> pause_sending();
    false -> continue_sending()
end.

Receive Backpressure

The peer can only send as fast as the owner reads. By default a stream earns the peer new credit as its data is delivered, and that credit waits while the owner's mailbox holds more than max_owner_queue messages (1000 by default).

When the owner hands data to another process, or processes it later, switch the stream to manual and return credit for what was processed. The peer then sends at most one stream window past what was consumed.

{quic, Conn, {stream_opened, StreamId}} ->
    ok = quic:set_stream_flow_control(Conn, StreamId, manual);
{quic, Conn, {stream_data, StreamId, Data, _Fin}} ->
    handle(Data),
    ok = quic:consume(Conn, StreamId, byte_size(Data))

Bytes delivered before the switch count as received and are consumed like any other.

Message Reference

Messages sent to the owner process:

MessageDescription
{quic, Conn, {connected, Info}}Connection established
{quic, Conn, {stream_opened, StreamId}}Peer opened a stream
{quic, Conn, {stream_data, StreamId, Data, Fin}}Data received
{quic, Conn, {stream_reset, StreamId, Code}}Stream reset by peer
{quic, Conn, {stop_sending, StreamId, Code}}Stop sending requested
{quic, Conn, {streams_available, Dir}}With notify_streams_available => true: an open refused with stream_limit in Dir (bidi or uni) would now succeed. One message per refusal
{quic, Conn, {datagram, Data}}Datagram received
{quic, Conn, {session_ticket, Ticket}}Session ticket for 0-RTT
{quic, Conn, {stream_deadline, StreamId}}Stream deadline expired
{quic, Conn, {send_ready, StreamId}}Stream ready to write
{quic, Conn, {closed, Reason}}Connection closed
{quic, Conn, {error, Reason}}Handshake failed, e.g. {certificate_invalid, _}
{quic, Conn, {transport_error, Code, Reason}}Transport error

Conn is always the connection handle returned by quic:connect/4.

Detecting stream closure

There is no separate stream_closed message. A stream ends in one of two ways:

  • Graceful: {stream_data, StreamId, _, true} - the Fin flag marks the end of the receive side.
  • Abrupt: {stream_reset, StreamId, Code} (peer reset) or {stop_sending, StreamId, Code} (peer asked us to stop).

Error Handling

%% Connection errors
case quic:connect(Host, Port, Opts, self()) of
    {ok, Conn} ->
        wait_for_connection(Conn);
    {error, Reason} ->
        handle_connect_error(Reason)
end.

%% Stream errors
case quic:send_data(Conn, StreamId, Data, true) of
    ok -> ok;
    {error, closed} -> connection_gone();
    {error, stream_closed} -> stream_gone();
    {error, flow_control} -> apply_backpressure()
end.

%% Handle connection close
receive
    {quic, Conn, {closed, normal}} ->
        ok;
    {quic, Conn, {closed, idle_timeout}} ->
        reconnect();
    {quic, Conn, {closed, handshake_timeout}} ->
        %% The handshake did not complete in time
        reconnect();
    {quic, Conn, {closed, {version_negotiation, Versions}}} ->
        %% The server speaks none of the QUIC versions we do
        log_versions(Versions);
    {quic, Conn, {closed, {tls_alert, Alert}}} ->
        %% The handshake failed with a TLS alert, e.g. no_application_protocol
        log_alert(Alert);
    {quic, Conn, {transport_error, Code, Reason}} ->
        log_error(Code, Reason)
end.

Best Practices

1. Certificate Verification

%% Production: always verify certificates
#{
    verify => true,
    cacerts => CACerts  %% list of DER-encoded CA certificates
}

%% Or name the PEM file and let the library read it. It is read once,
%% and a path that cannot be read fails the connect rather than quietly
%% verifying against the OS trust store instead.
#{
    verify => true,
    cacertfile => "/etc/ssl/certs/our-ca.pem"
}

%% Development only: disable verification
#{verify => false}

The name in server_name (or the host you passed to quic:connect/4) is matched against the certificate's subjectAltName under RFC 9525: a *.example.com SAN matches host.example.com, but not example.com and not a.b.example.com. A wildcard counts only as the whole leftmost label, and a certificate with no DNS or IP subjectAltName matches no name; its subject CN is not used. When the host is an IP address (a tuple or a string, an IPv6 address bracketed or not, such as "[::1]") and you set no server_name, the address must appear in the certificate's IP subjectAltName entries, and no SNI is sent. The same holds for an address given as server_name.

The certificate must also be one for a TLS server. When it has an Extended Key Usage extension, that must list serverAuth (or anyExtendedKeyUsage), and when it has a Key Usage extension, digitalSignature must be set. A certificate for another purpose, such as code signing, is refused with {bad_cert, {extended_key_usage, server_auth}} or {bad_cert, key_usage}. A certificate you list yourself in cacerts, such as a pinned self-signed one, is taken as you configured it and not checked for usage.

2. Connection Pooling

%% For multiple requests to same server, reuse connections
%% Open multiple streams on single connection
{ok, Conn} = quic:connect(Host, Port, Opts, self()),

%% Concurrent requests on same connection
{ok, Stream1} = quic:open_stream(Conn),
{ok, Stream2} = quic:open_stream(Conn),
{ok, Stream3} = quic:open_stream(Conn).

3. Graceful Shutdown

%% Close streams before closing connection
lists:foreach(fun(StreamId) ->
    quic:send_data(Conn, StreamId, <<>>, true)
end, OpenStreams),

%% Wait for acknowledgment, then close
timer:sleep(100),
quic:close(Conn, normal).

4. Timeout Handling

%% Set appropriate timeouts
connect_with_timeout(Host, Port) ->
    {ok, Conn} = quic:connect(Host, Port, #{
        idle_timeout => 30000
    }, self()),

    receive
        {quic, Conn, {connected, _}} ->
            {ok, Conn}
    after 10000 ->
        quic:close(Conn, timeout),
        {error, connection_timeout}
    end.

5. Enable QLOG for Debugging

%% Enable QLOG to debug connection issues
quic:connect(Host, Port, #{
    qlog => #{
        enabled => true,
        dir => "/tmp/qlog"
    }
}, self()).

%% View with: qvis or Wireshark

Example: HTTP/3-style Client

-module(h3_client).
-export([request/3]).

request(Host, Port, Path) ->
    %% Connect
    {ok, Conn} = quic:connect(Host, Port, #{
        alpn => [<<"h3">>],
        verify => false
    }, self()),

    receive
        {quic, Conn, {connected, _}} -> ok
    after 5000 ->
        quic:close(Conn, timeout),
        exit(connection_timeout)
    end,

    %% Open request stream
    {ok, StreamId} = quic:open_stream(Conn),

    %% Send request (simplified, not real H3)
    Request = <<"GET ", Path/binary, " HTTP/3\r\n\r\n">>,
    ok = quic:send_data(Conn, StreamId, Request, true),

    %% Receive response
    Response = receive_response(Conn, StreamId, <<>>),

    quic:close(Conn, normal),
    Response.

receive_response(Conn, StreamId, Acc) ->
    receive
        {quic, Conn, {stream_data, StreamId, Data, false}} ->
            receive_response(Conn, StreamId, <<Acc/binary, Data/binary>>);
        {quic, Conn, {stream_data, StreamId, Data, true}} ->
            <<Acc/binary, Data/binary>>;
        {quic, Conn, {closed, _}} ->
            Acc
    after 10000 ->
        Acc
    end.

Troubleshooting

Connection Fails

  1. Check server is reachable: nc -u <host> <port>
  2. Verify ALPN matches server's protocols
  3. Check certificate issues with verify => false first
  4. Enable QLOG to see handshake details

Slow Performance

  1. Check for packet loss with QLOG
  2. Verify MTU discovery is working: quic:get_mtu(Conn)
  3. Monitor congestion: quic:get_send_queue_info(Conn)
  4. Consider datagram API for latency-sensitive data

Connection Drops

  1. Check idle_timeout settings on both ends
  2. Enable keep-alive: keep_alive_interval => 15000
  3. Monitor for transport errors in messages