Previous parts:
What is HTTP/3 datagrams?
The HTTP/3 datagrams is an HTTP extension, defined in RFC 9297. It builds on top of QUIC datagrams to send unreliable data within the context of an HTTP/3 connection.
HTTP/3 datagrams are bound to an HTTP request, which makes it possible to send data in the context of an HTTP request. This means you can, for example, authenticate a request before sending datagrams, or receive special parameters on how to transmit datagrams. Primary use-cases can include video/audio streaming or gaming, where data is time-sensitive, and a lost packet is better than a delayed one.
Negotiating the use of QUIC DATAGRAM frames for HTTP Datagrams is achieved via the exchange of HTTP/3 settings.
We will also be doing this. Both sides must support datagrams to be able to communicate.
This post will cover the same topic as the official documentation on HTTP Datagrams, but with more implementation details that I found were missing when I started. I hope this helps you avoid making the same mistakes. We will implement a simple ping-pong client and server which will communicate with each other over datagrams.
Server
Let’s start with the server. First we need to generate TLS certificate for it, see how it’s done here.
cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
During server setup, we need to enable datagrams support. This will tell the client during the settings negotiation process that we are able to communicate via datagrams. If you do not enable it, datagrams sent from the client will be ignored.
srv := &http3.Server{
Addr: "127.0.0.1:8080",
Handler: mux,
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: []string{http3.NextProtoH3}, // tell peer server supports HTTP/3 protocol
},
EnableDatagrams: true, // enable datagrams support
}
On each incoming request, we need to check if the client supports datagrams. If not, we should reject the request. At this point, we can also authenticate the client, receive additional configuration, etc.
func pingPongHandler(w http.ResponseWriter, r *http.Request) {
conn := w.(http3.Hijacker).Connection()
select {
case <-conn.ReceivedSettings():
case <-time.After(10 * time.Second):
// didn't receive SETTINGS within 10 seconds
http.Error(w, "timeout waiting for SETTINGS", http.StatusBadRequest)
return
}
// check that HTTP Datagram support is enabled
settings := conn.Settings()
if !settings.EnableDatagrams {
http.Error(w, "datagram support not enabled", http.StatusBadRequest)
return
}
// responding with OK 200 header
w.WriteHeader(http.StatusOK)
...
Next, we overtake the HTTP/3 stream:
streamer := w.(http3.HTTPStreamer)
http3Stream := streamer.HTTPStream()
Now, we can send and receive datagrams:
// sending HTTP datagram
err := http3Stream.SendDatagram([]byte("byte"))
// receiving HTTP datagram
datagram, err := http3Stream.ReceiveDatagram(ctx)
Client
The client part is a little bit trickier. We’ll need to create a raw QUIC connection to the server and start HTTP on top of it.
Starting simple by loading certificates:
certPool := x509.NewCertPool()
certData, err := os.ReadFile("cert.pem")
...
certPool.AppendCertsFromPEM(certData)
tlsConf := &tls.Config{
RootCAs: certPool, // use the cert pool with server's cert
NextProtos: []string{http3.NextProtoH3}, // use HTTP/3 protocol
}
Specifying the supported protocol is an important part of negotiating protocols during the TLS handshake.
Otherwise, we’ll receive a CRYPTO_ERROR 0x178 (remote): tls: no application protocol"
error.
Setting up support for datagrams: HTTP/3 datagrams are built on top of QUIC datagrams, so we need to enable QUIC datagrams first in the QUIC config. Then, on the HTTP level, we also indicate that we support HTTP datagrams. Remember we were checking for datagrams support on the server side?
quicConf := &quic.Config{
EnableDatagrams: true, // enable QUIC datagrams support
}
tr := &http3.Transport{
EnableDatagrams: true, // enable support for HTTP/3 datagrams
}
Now, let’s dial the server to create a QUIC connection.
We’ll use the convenience method quic.DialAddr
. For more advanced use-cases, create
and configure a quic.Transport
.
quicConn, err := quic.DialAddr(ctx, "localhost:8080", tlsConf, quicConf)
Then, create a new HTTP/3 connection using the transport on top of it:
http3Conn := tr.NewClientConn(quicConn)
On the client side, we also need to ensure that the server supports HTTP datagrams. We wait to receive the server’s settings to check for datagram support. These settings can also be used to carry additional information about what the server supports, like WebTransport.
// wait for the server's SETTINGS
select {
case <-http3Conn.ReceivedSettings():
case <-http3Conn.Context().Done():
// connection closed
return fmt.Errorf("connection closed before receiving SETTINGS")
}
settings := http3Conn.Settings()
if !settings.EnableDatagrams {
// no datagram support, closing connection
http3Conn.CloseWithError(http3., "datagram support not enabled")
return fmt.Errorf("server does not support datagrams")
}
Now we need to send an HTTP request to the server. To do this, we first create a new request stream:
reqStream, err := http3Conn.OpenRequestStream(context.Background())
Then, we send a request and wait for a response to ensure we are good to go:
req, err := http.NewRequestWithContext(ctx, http.MethodConnect, "https://localhost:8080/ping-pong", http.NoBody)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if err := reqStream.SendRequestHeader(req); err != nil {
return fmt.Errorf("send request: %w", err)
}
resp, err := reqStream.ReadResponse()
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK { // checking the server's response to see if we can start datagram exchange
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
Finally, we can send and receive datagrams:
datagram, err = reqStream.ReceiveDatagram(ctx)
...
err := reqStream.SendDatagram([]byte("hello")
Let’s try it all together by doing a ping-pong between the server and the client. The full code is a bit too long to include in the post, but you can find the complete source here.
If you run go run ./main.go
, you should see output similar to this:
2025/07/08 22:59:35 INFO client: received datagram value=0
2025/07/08 22:59:35 INFO server: received datagram value=1
2025/07/08 22:59:35 INFO client: received datagram value=2
2025/07/08 22:59:35 INFO server: received datagram value=3
2025/07/08 22:59:35 INFO client: received datagram value=4
2025/07/08 22:59:35 INFO server: received datagram value=5
2025/07/08 22:59:35 INFO client: received datagram value=6
2025/07/08 22:59:35 INFO server: received datagram value=7
2025/07/08 22:59:35 INFO client: received datagram value=8
2025/07/08 22:59:35 INFO server: received datagram value=9
2025/07/08 22:59:35 INFO server: sent final datagram, exiting
2025/07/08 22:59:35 INFO client: received datagram value=10
2025/07/08 22:59:35 INFO client: received final datagram, exiting
2025/07/08 22:59:35 INFO server closed
That’s it. Have fun exploring and building new things!
If you want to explore futher, take look at WebSockets with HTTP/3 (RFC9220) and WebTransport over HTTP/3.