This guide covers hackney's HTTP/3 support via QUIC.
Hackney supports HTTP/3, the latest version of HTTP built on QUIC (UDP-based transport). HTTP/3 offers improved performance, especially on lossy networks, with features like connection migration and zero round-trip connection establishment.
- QUIC transport - UDP-based, encrypted by default with TLS 1.3
- Transparent API - Same
hackney:get/post/requestfunctions work for HTTP/3 - Multiplexing - Multiple streams without head-of-line blocking
- Alt-Svc discovery - Automatic HTTP/3 endpoint detection from Alt-Svc headers
- Connection pooling - HTTP/3 connections shared across callers
- Negative caching - Failed H3 attempts cached to avoid repeated failures
HTTP/3 support is provided by the erlang_quic dependency (module quic_h3), which handles the QUIC transport, QPACK header compression, HTTP/3 framing, and control streams. Hackney hosts only a thin adapter (hackney_h3) that translates quic_h3 events into the internal connection protocol. No C dependencies, no external binaries required.
%% HTTP/3 request with explicit protocol selection
{ok, 200, Headers, Body} = hackney:get(
<<"https://cloudflare.com/cdn-cgi/trace">>,
[],
<<>>,
[{protocols, [http3]}, with_body]
).
%% Body contains: http=http/3By default, hackney uses HTTP/2 and HTTP/1.1 (not HTTP/3):
%% Default: [http2, http1]
hackney:get(<<"https://example.com/">>).Add http3 to the protocols list:
%% Try HTTP/3 first, fall back to HTTP/2, then HTTP/1.1
hackney:get(URL, [], <<>>, [{protocols, [http3, http2, http1]}]).%% HTTP/3 only - fails if H3 unavailable
hackney:get(URL, [], <<>>, [{protocols, [http3]}]).hackney:get(URL, [], <<>>, [{protocols, [http2]}]).hackney:get(URL, [], <<>>, [{protocols, [http1]}]).Check the negotiated protocol on a connection:
{ok, Conn} = hackney:connect(hackney_ssl, "cloudflare.com", 443,
[{protocols, [http3]}]),
Protocol = hackney_conn:get_protocol(Conn). %% http3 | http2 | http1
hackney:close(Conn).Or verify via Cloudflare's trace endpoint:
{ok, 200, _, Body} = hackney:get(
<<"https://cloudflare.com/cdn-cgi/trace">>,
[], <<>>,
[{protocols, [http3]}, with_body]
),
%% Body contains "http=http/3" if using HTTP/3Servers advertise HTTP/3 support via the Alt-Svc response header:
Alt-Svc: h3=":443"; ma=86400
Hackney automatically caches these and uses HTTP/3 on subsequent requests:
%% First request uses HTTP/2 or HTTP/1.1
%% Server returns Alt-Svc: h3=":443"; ma=86400
{ok, _, Headers1, _} = hackney:get(URL, [], <<>>, [{protocols, [http3, http2, http1]}]).
%% Alt-Svc is now cached, second request uses HTTP/3
{ok, _, Headers2, _} = hackney:get(URL, [], <<>>, [{protocols, [http3, http2, http1]}]).%% Check if HTTP/3 is cached for a host
hackney_altsvc:lookup(<<"example.com">>, 443).
%% {ok, h3, 443} | none
%% Manually cache HTTP/3 endpoint
hackney_altsvc:cache(<<"example.com">>, 443, 443, 86400).
%% Clear cached entry
hackney_altsvc:clear(<<"example.com">>, 443).
%% Clear all cached entries
hackney_altsvc:clear_all().Like HTTP/2, HTTP/3 multiplexes requests as streams on a single QUIC connection:
%% All requests share ONE QUIC connection
{ok, _, _, _} = hackney:get(<<"https://cloudflare.com/">>,
[], <<>>, [{protocols, [http3]}]).
{ok, _, _, _} = hackney:get(<<"https://cloudflare.com/cdn-cgi/trace">>,
[], <<>>, [{protocols, [http3]}]).┌─────────────────────────────────────────────────────────────────┐
│ hackney_pool │
│ │
│ h3_connections = #{ {Host, Port, Transport} => Pid } │
│ │
│ checkout_h3(Host, Port, ...) -> │
│ case maps:get(Key, h3_connections) of │
│ Pid -> {ok, Pid}; %% Reuse existing │
│ undefined -> none %% Create new │
│ end │
│ │
│ register_h3(Host, Port, ..., Pid) -> │
│ h3_connections#{Key => Pid} %% Store for reuse │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ hackney_conn (gen_statem process) │
│ │
│ h3_conn = <QUIC connection reference> │
│ │
│ h3_streams = #{ │
│ 0 => {CallerA, waiting_headers, <<>>}, │
│ 4 => {CallerB, waiting_headers, <<>>}, │
│ 8 => {CallerC, waiting_headers, <<>>} │
│ } │
│ │
│ Request from CallerA → open_stream() → StreamId=0 │
│ Request from CallerB → open_stream() → StreamId=4 │
│ Request from CallerC → open_stream() → StreamId=8 │
│ │
│ Response for StreamId=4 arrives: │
│ → lookup h3_streams[4] → CallerB │
│ → gen_statem:reply(CallerB, {ok, Status, Headers, Body}) │
└─────────────────────────────────────────────────────────────────┘
The high-level hackney:get/post/... functions cover the common case. For
servers that send streamed responses, or when you want to drive several
requests concurrently on the same QUIC connection, use the hackney_h3
adapter directly.
{ok, ConnRef} = hackney_h3:connect(<<"cloudflare.com">>, 443, #{}, self()).
receive
{h3, ConnRef, {connected, _Info}} -> ok
after 5000 ->
error(connect_timeout)
end.hackney_h3:connect/4 registers the calling process as the owner of the
connection. All events for the connection arrive as messages of the form
{h3, ConnRef, Event}.
send_request/3 opens a request stream and sends the HEADERS frame in one
shot. Pass Fin = true when the request has no body, false if you will
follow up with send_data/4:
Headers = [
{<<":method">>, <<"GET">>},
{<<":scheme">>, <<"https">>},
{<<":authority">>, <<"cloudflare.com">>},
{<<":path">>, <<"/cdn-cgi/trace">>}
],
{ok, StreamId} = hackney_h3:send_request(ConnRef, Headers, true).For requests with a body:
{ok, StreamId} = hackney_h3:send_request(ConnRef, Headers, false),
ok = hackney_h3:send_data(ConnRef, StreamId, <<"chunk-1">>, false),
ok = hackney_h3:send_data(ConnRef, StreamId, <<"chunk-2">>, true). %% FinThe owner process receives a response as a sequence of events tagged with
the StreamId:
recv(ConnRef, StreamId, Status, Headers, Body) ->
receive
{h3, ConnRef, {stream_headers, StreamId, RespHeaders, _Fin}} ->
{<<":status">>, S} = lists:keyfind(<<":status">>, 1, RespHeaders),
recv(ConnRef, StreamId, binary_to_integer(S),
[H || {K, _} = H <- RespHeaders, K =/= <<":status">>],
Body);
{h3, ConnRef, {stream_data, StreamId, Chunk, true}} ->
{ok, Status, Headers, <<Body/binary, Chunk/binary>>};
{h3, ConnRef, {stream_data, StreamId, Chunk, false}} ->
recv(ConnRef, StreamId, Status, Headers, <<Body/binary, Chunk/binary>>);
{h3, ConnRef, {stream_reset, StreamId, ErrorCode}} ->
{error, {stream_reset, ErrorCode}};
{h3, ConnRef, {closed, Reason}} ->
{error, {closed, Reason}}
after 30000 ->
{error, timeout}
end.The Fin = true flag on a stream_data event marks end-of-stream. For
header-only responses (HEAD, 204, 304) the adapter still emits a final
{stream_data, StreamId, <<>>, true} so this loop terminates the same way.
Since each request gets its own StreamId, you can have several in flight
on the same QUIC connection and demultiplex on the StreamId in your receive:
{ok, S1} = hackney_h3:send_request(ConnRef, headers(<<"/">>), true),
{ok, S2} = hackney_h3:send_request(ConnRef, headers(<<"/cdn-cgi/trace">>), true),
{ok, S3} = hackney_h3:send_request(ConnRef, headers(<<"/robots.txt">>), true),
%% Collect responses as they complete; order is not guaranteed.
collect(ConnRef, sets:from_list([S1, S2, S3]), #{}).
collect(_ConnRef, Pending, Acc) when map_size(Acc) =:= sets:size(Pending) ->
Acc;
collect(ConnRef, Pending, Acc) ->
receive
{h3, ConnRef, {stream_headers, SId, Hs, _}} ->
collect(ConnRef, Pending, Acc#{SId => {Hs, <<>>}});
{h3, ConnRef, {stream_data, SId, Chunk, true}} ->
#{SId := {Hs, Body}} = Acc,
collect(ConnRef, Pending, Acc#{SId => {Hs, <<Body/binary, Chunk/binary>>}});
{h3, ConnRef, {stream_data, SId, Chunk, false}} ->
#{SId := {Hs, Body}} = Acc,
collect(ConnRef, Pending, Acc#{SId => {Hs, <<Body/binary, Chunk/binary>>}})
end.Use reset_stream/3 to abort a single in-flight request without tearing
down the connection:
ok = hackney_h3:reset_stream(ConnRef, StreamId, 16#0102). %% H3_REQUEST_CANCELLEDhackney_h3:close(ConnRef, normal).| Event | Meaning |
|---|---|
{connected, Info} |
QUIC + H3 handshake complete |
{stream_headers, StreamId, Headers, Fin} |
Response headers (or trailers when Fin = true) |
{stream_data, StreamId, Bin, Fin} |
Response body chunk; Fin = true ends the stream |
{stream_reset, StreamId, ErrorCode} |
Peer reset the stream |
{goaway, LastStreamId} |
Peer is shutting down; finish in-flight streams |
{closed, Reason} |
Connection closed |
{transport_error, Code, Reason} |
QUIC transport error |
Some networks block UDP traffic, which prevents HTTP/3 from working. Hackney handles this with negative caching:
%% If HTTP/3 fails, host is marked as blocked for 5 minutes
%% Subsequent requests skip HTTP/3 and use HTTP/2 or HTTP/1.1
%% Check if host is marked as H3-blocked
hackney_altsvc:is_h3_blocked(<<"example.com">>, 443). %% true | false
%% Manually mark as blocked (e.g., for testing)
hackney_altsvc:mark_h3_blocked(<<"example.com">>, 443).| Feature | HTTP/3 | HTTP/2 |
|---|---|---|
| Transport | QUIC (UDP) | TCP |
| TLS | Built-in (TLS 1.3) | Separate layer |
| Head-of-line blocking | Per-stream only | Connection-wide |
| Connection migration | Supported | Not supported |
| 0-RTT resumption | Supported | Not supported |
Both HTTP/2 and HTTP/3 use lowercase header names:
%% HTTP/3 headers (same as HTTP/2)
[{<<":status">>, <<"200">>},
{<<"content-type">>, <<"text/html">>},
{<<"server">>, <<"cloudflare">>}]case hackney:get(URL, [], <<>>, [{protocols, [http3]}]) of
{ok, Status, Headers, Body} ->
ok;
{error, {quic_error, Code, Reason}} ->
%% QUIC-level error
io:format("QUIC error ~p: ~s~n", [Code, Reason]);
{error, timeout} ->
%% Connection timeout (possibly UDP blocked)
io:format("Timeout - UDP may be blocked~n");
{error, Reason} ->
io:format("Error: ~p~n", [Reason])
end.HTTP/3's per-stream flow control and connection migration work well on mobile or lossy networks:
%% Good for mobile apps
Opts = [{protocols, [http3, http2, http1]}, {connect_timeout, 10000}].HTTP/3 connections are expensive to establish. Use pooling:
%% Good: connections are reused via pool
[hackney:get(URL, [], <<>>, [{pool, default}, {protocols, [http3]}])
|| _ <- lists:seq(1, 100)].
%% Bad: new QUIC handshake each time
[hackney:get(URL, [], <<>>, [{pool, false}, {protocols, [http3]}])
|| _ <- lists:seq(1, 100)].HTTP/3 requires servers that support:
- QUIC (RFC 9000)
- HTTP/3 (RFC 9114)
Major CDNs with HTTP/3 support:
- Cloudflare
- Fastly
- Akamai
# Using curl
curl -v --http3 https://cloudflare.com/ 2>&1 | grep -i http/3
# Check Alt-Svc header
curl -v https://cloudflare.com/ 2>&1 | grep -i alt-svcIf HTTP/3 is unavailable, hackney falls back to HTTP/2 or HTTP/1.1:
%% Works regardless of H3 support (if http2/http1 in protocols)
{ok, _, _, _} = hackney:get(URL, [], <<>>,
[{protocols, [http3, http2, http1]}]).# Start hackney
Application.ensure_all_started(:hackney)
# HTTP/3 request
{:ok, status, headers, body} = :hackney.get(
"https://cloudflare.com/cdn-cgi/trace",
[],
"",
[{:protocols, [:http3]}, :with_body]
)
# Verify HTTP/3
String.contains?(body, "http=http/3") # true%% HTTP/3 only - fails if server doesn't support it or UDP blocked
{ok, _, _, _} = hackney:get(URL, [], <<>>, [
with_body,
{protocols, [http3]}
]).
%% HTTP/2 only - never uses HTTP/3
{ok, _, _, _} = hackney:get(URL, [], <<>>, [
with_body,
{protocols, [http2]}
]).-
Check if
http3is in protocols list -
Check if host is marked as blocked:
hackney_altsvc:is_h3_blocked(Host, Port).
-
Verify server supports HTTP/3:
curl -v --http3 https://example.com/
UDP may be blocked by firewalls. Try:
- Use fallback protocols:
{protocols, [http3, http2, http1]} - Check if other HTTP/3 sites work (e.g., cloudflare.com)
- Check firewall/network settings for UDP port 443
- HTTP/2 Guide - HTTP/2 features
- HTTP Guide - General HTTP features
- Design Guide - Architecture details