QUIC Client Guide
View SourceThis 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
| Option | Type | Default | Description |
|---|---|---|---|
alpn | [binary()] | [<<"h3">>] | ALPN protocols to offer |
verify | boolean | true | Verify server certificate (verify_peer / verify_none also accepted) |
server_name | binary | Host | Server Name Indication |
cert | binary | - | Client certificate (for mTLS) |
key | term | - | 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 list | Advertised signature schemes (ecdsa_secp256r1_sha256, ecdsa_secp384r1_sha384, rsa_pss_rsae_sha256|384|512, ed25519, rsa_pkcs1_sha256). |
external_psk | tuple | - | 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
| Option | Type | Default | Description |
|---|---|---|---|
idle_timeout | integer | 30000 | Idle timeout in ms |
max_data | integer | 786432 | Connection-level receive limit |
max_stream_data_bidi_local | integer | 524288 | Receive limit for streams we open |
max_stream_data_bidi_remote | integer | 524288 | Receive limit for streams the peer opens |
max_stream_data_uni | integer | 524288 | Receive limit for unidirectional streams |
max_receive_window | integer | 8388608 | Largest window the receive limits grow to; at least 16384 |
max_owner_queue | integer | infinity | 1000 | Receive credit waits, and DATAGRAMs are dropped, while the owner's mailbox holds more messages than this |
max_streams_bidi | integer | 100 | Max bidirectional streams |
max_streams_uni | integer | 100 | Max unidirectional streams |
Datagram Options (RFC 9221)
| Option | Type | Default | Description |
|---|---|---|---|
max_datagram_frame_size | integer | 0 | Max datagram size (0 = disabled) |
Socket Options
| Option | Type | Default | Description |
|---|---|---|---|
socket | gen_udp:socket() | - | Pre-opened UDP socket |
extra_socket_opts | list() | [] | Options for socket creation |
Address Resolution Options
| Option | Type | Default | Description |
|---|---|---|---|
happy_eyeballs | boolean | true | Race IPv6/IPv4 for dual-stack hostnames (RFC 8305) |
family | inet | inet6 | any | any | Restrict resolution to one address family |
connection_attempt_delay | integer | 250 | Happy Eyeballs stagger between attempts (ms) |
connect_timeout | integer | 5000 | Overall Happy Eyeballs deadline (ms) |
Advanced Options
| Option | Type | Default | Description |
|---|---|---|---|
keep_alive_interval | integer/atom | disabled | PING interval in ms (min 5000), or auto for half the idle timeout |
pmtu_enabled | boolean | true | Enable Path MTU Discovery |
max_udp_payload_size | integer | 1472 (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()).
%% Check if datagrams are supported
MaxSize = quic:datagram_max_size(Conn),
case MaxSize of
0 -> io:format("Datagrams not supported~n");
_ -> io:format("Max datagram size: ~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;
{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 changedWhat happens during migration:
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.
Rebind local socket - Client closes old socket, opens new one on a different local port (simulating a network change like WiFi to cellular).
Send PATH_CHALLENGE - Client sends a PATH_CHALLENGE frame to the same server address but from its new local address.
Receive PATH_RESPONSE - Server echoes the challenge data back, proving it can reach the client's new address.
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 migrateSocket 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 tupleWhen 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:
| Message | Description |
|---|---|
{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, {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}- theFinflag 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, not_found} -> 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, {version_negotiation, Versions}}} ->
%% The server speaks none of the QUIC versions we do
log_versions(Versions);
{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 under the RFC 6125 HTTPS rules: a *.example.com
SAN matches host.example.com, but not example.com and not a.b.example.com.
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 WiresharkExample: 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
- Check server is reachable:
nc -u <host> <port> - Verify ALPN matches server's protocols
- Check certificate issues with
verify => falsefirst - Enable QLOG to see handshake details
Slow Performance
- Check for packet loss with QLOG
- Verify MTU discovery is working:
quic:get_mtu(Conn) - Monitor congestion:
quic:get_send_queue_info(Conn) - Consider datagram API for latency-sensitive data
Connection Drops
- Check
idle_timeoutsettings on both ends - Enable keep-alive:
keep_alive_interval => 15000 - Monitor for transport errors in messages