QUIC connections are actually two streams: one for reliable transport and one for unreliable datagrams, even though you only ever open one "stream" in the API.
Let’s see this in action. We’ll set up a simple server and client that exchange a few messages.
First, the server:
package main
import (
"context"
"crypto/tls"
"fmt"
"io"
"log"
"net"
"github.com/quic-go/quic-go"
)
func main() {
// Generate a self-signed certificate for TLS
tlsConf := &tls.Config{
InsecureSkipVerify: true, // Only for testing, do not use in production
NextProtos: []string{"quic-example"},
}
listener, err := quic.ListenAddr("localhost:8080", tlsConf, &quic.Config{
EnableDatagrams: true, // Enable datagram support
})
if err != nil {
log.Fatal(err)
}
defer listener.Close()
log.Println("QUIC server started on localhost:8080")
for {
conn, err := listener.Accept(context.Background())
if err != nil {
log.Printf("Error accepting connection: %v", err)
continue
}
go handleConnection(conn)
}
}
func handleConnection(conn quic.Connection) {
log.Printf("New connection from %s", conn.RemoteAddr())
defer conn.CloseWithError(0, "") // Close connection on exit
// Handle reliable streams
go func() {
for {
stream, err := conn.AcceptStream(context.Background())
if err != nil {
log.Printf("Error accepting stream: %v", err)
return
}
go handleStream(stream)
}
}()
// Handle datagrams
go func() {
for {
data, err := conn.ReceiveDatagram(context.Background())
if err != nil {
log.Printf("Error receiving datagram: %v", err)
return
}
fmt.Printf("Datagram received: %s\n", string(data))
// Echo datagram back
err = conn.SendDatagram([]byte("Echo: " + string(data)))
if err != nil {
log.Printf("Error sending datagram: %v", err)
return
}
}
}()
}
func handleStream(stream quic.Stream) {
defer stream.Close()
log.Printf("New stream opened: %d", stream.StreamID())
buf := make([]byte, 1024)
for {
n, err := stream.Read(buf)
if err != nil {
if err == io.EOF {
log.Printf("Stream %d closed by peer", stream.StreamID())
return
}
log.Printf("Error reading from stream %d: %v", stream.StreamID(), err)
return
}
message := string(buf[:n])
fmt.Printf("Stream %d received: %s\n", stream.StreamID(), message)
// Echo message back
_, err = stream.Write([]byte("Echo: " + message))
if err != nil {
log.Printf("Error writing to stream %d: %v", stream.StreamID(), err)
return
}
}
}
And the client:
package main
import (
"context"
"crypto/tls"
"fmt"
"io"
"log"
"time"
"github.com/quic-go/quic-go"
)
func main() {
// TLS configuration
tlsConf := &tls.Config{
InsecureSkipVerify: true, // Only for testing, do not use in production
NextProtos: []string{"quic-example"},
}
// Connect to the server
conn, err := quic.DialAddrContext(context.Background(), "localhost:8080", tlsConf, &quic.Config{
EnableDatagrams: true, // Enable datagram support
})
if err != nil {
log.Fatal(err)
}
defer conn.CloseWithError(0, "")
log.Println("QUIC client connected to localhost:8080")
// Open a reliable stream
stream, err := conn.OpenStreamSync(context.Background())
if err != nil {
log.Fatal(err)
}
defer stream.Close()
// Send a message over the reliable stream
message := "Hello, QUIC stream!"
_, err = stream.Write([]byte(message))
if err != nil {
log.Fatal(err)
}
log.Printf("Sent to stream: %s", message)
// Read the echoed message from the stream
buf := make([]byte, 1024)
n, err := stream.Read(buf)
if err != nil {
log.Fatal(err)
}
log.Printf("Received from stream: %s", string(buf[:n]))
// Send a datagram
datagramMessage := "Hello, QUIC datagram!"
err = conn.SendDatagram([]byte(datagramMessage))
if err != nil {
log.Fatal(err)
}
log.Printf("Sent datagram: %s", datagramMessage)
// Wait for the echoed datagram (with a timeout)
// Datagrams are unordered and can be lost, so we might need to retry or have a timeout.
// For simplicity, we'll just wait a bit and hope it arrives.
time.Sleep(100 * time.Millisecond) // Give it time to arrive
// In a real application, you'd likely have a mechanism to receive and match datagrams.
// For this example, we'll just demonstrate sending.
log.Println("Datagram sent. Waiting for echo might involve a separate receive loop.")
// Example of how you might receive datagrams on the client (if server also sent them)
// This would typically be in its own goroutine.
go func() {
for {
data, err := conn.ReceiveDatagram(context.Background())
if err != nil {
log.Printf("Client error receiving datagram: %v", err)
return
}
fmt.Printf("Client received datagram: %s\n", string(data))
}
}()
// Keep the client running for a bit to receive potential datagrams
time.Sleep(2 * time.Second)
}
When you run these, you’ll see the server receive both stream data and datagrams, and echo them back. Notice how the server handles streams via AcceptStream and datagrams via ReceiveDatagram on the same quic.Connection object. The EnableDatagrams: true in the quic.Config is crucial here.
The core of QUIC, and what makes it different from TCP, is its multiplexing capabilities built on UDP. A single QUIC connection can carry multiple independent streams, and crucially for us here, it can also carry unreliable datagrams alongside these reliable streams. This is handled at the transport layer by QUIC itself, not by the application. When quic-go is configured with EnableDatagrams: true, it sets up internal mechanisms to manage both reliable stream data and best-effort datagrams over the same UDP connection.
The quic.Connection object in quic-go is your unified interface. You OpenStreamSync or AcceptStream for reliable, ordered data. You SendDatagram and ReceiveDatagram for unreliable, unordered data. This duality is key. The underlying QUIC protocol handles the complexity of ensuring stream data arrives in order and is retransmitted if lost, while datagrams are sent as UDP packets, subject to the usual internet best-effort delivery.
A common point of confusion is how to differentiate between stream data and datagrams on the receiving end. The quic.Connection object provides distinct methods for each: AcceptStream for streams and ReceiveDatagram for datagrams. You’ll typically run these in separate goroutines. The server example shows this clearly: one goroutine loop for accepting streams and another for receiving datagrams, both operating on the conn object.
The quic.Config struct is where you enable or disable features. EnableDatagrams: true is the switch. Without it, SendDatagram and ReceiveDatagram would likely error or be no-ops. The TLS configuration is also essential because QUIC mandates encryption. InsecureSkipVerify: true is used here for simplicity in a local test environment, but for any real-world application, you must configure proper certificate validation.
The most surprising thing about QUIC’s datagram support is that it’s not just an afterthought; it’s a first-class citizen integrated directly into the transport protocol. Unlike TCP, which has no native datagram equivalent, QUIC allows you to send both reliable streams and unreliable datagrams over the same connection, managed by the protocol itself. This means you don’t need to build your own UDP-based unreliable messaging layer on top of QUIC streams or a separate UDP socket.
The next logical step is to explore how to handle multiple streams concurrently within a single connection and how to implement more robust datagram handling, including acknowledgments or sequence numbering for datagrams if you need some level of reliability on top of the unreliable transport.