While designing a real-time data streaming platform, I was faced with a tricky decision:
Should I use WebSocket? It works, but it runs on top of TCP, which suffers from Head-of-Line (HoL) Blocking. If a single packet gets lost in transit, all subsequent packets must queue up and wait for the retransmission, regardless of whether they have already arrived. This is a major bottleneck for high-frequency real-time streams.
After searching for alternatives, I decided to go with WebTransport.
To be honest, resources and practical guides on this technology are scarce. After spending quite some time troubleshooting and debugging, I've compiled my findings into this guide to help you build a production-ready WebTransport real-time push service from scratch in Go.
1. Why WebTransport Over WebSocket?
To understand this, we need to look at how WebSockets work under the hood.
WebSocket is established over TCP. TCP is a highly reliable transport protocol with a core guarantee: data packets must arrive in order, with zero losses.
While this reliability is great for most web applications, it causes issues in real-time streaming:
Suppose the server pushes four messages sequentially: A, B, C, and D. Message B is dropped during transmission. TCP detects this loss, triggers a retransmission, and blocks C and D in the buffer until B arrives—even though C and D have already reached the client.
This is TCP Head-of-Line (HoL) Blocking. In real-time scenarios, it doesn't matter if message B arrives slightly late, but it shouldn't block subsequent messages from rendering immediately.
WebTransport runs on QUIC, which operates over UDP. Its core mechanics are:
- High-frequency real-time data is sent via Datagrams, which allow packet loss but offer ultra-low latency.
- Crucial notifications (e.g., system messages) can be sent via Streams, which guarantee ordered, loss-free delivery at the QUIC level. Since streams are independent, a packet drop in one stream does not block others.
This design makes it highly suitable for real-time pushing:
- Real-Time Streams (e.g., monitoring metrics, live chats) → Datagrams (low latency, tolerates occasional drops).
- Critical Alerts → Streams (guaranteed delivery).
2. Common Pitfalls to Avoid Upfront
Before writing any code, keep these three major pitfalls in mind to save hours of troubleshooting.
Pitfall 1: HTTPS is Mandatory
WebTransport strictly requires TLS encryption. For local development, you must generate a self-signed certificate.
bash
mkdir -p certs
# Generate self-signed certificates for local development using OpenSSL
openssl req -x509 -newkey rsa:4096 \
-keyout certs/key.pem \
-out certs/cert.pem \
-days 365 -nodes \
-subj '/CN=localhost'
This command generates cert.pem (certificate) and key.pem (private key) inside your certs/ directory.
Pitfall 2: Browsers Reject Self-Signed Certificates by Default
Chrome will reject WebTransport connections built on self-signed certificates unless configured otherwise. You can start Chrome via terminal with these flags:
bash
# macOS command to start Chrome while ignoring certificate errors
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--ignore-certificate-errors \
--ignore-certificate-errors-spki-list=<YOUR_CERTIFICATE_FINGERPRINT>
Alternatively, open chrome://flags/#allow-insecure-localhost in your browser and enable the flag to bypass certificate errors for localhost.
Note: Safari does not support WebTransport, and Firefox's support is limited. Use Chrome or Edge for testing.
Pitfall 3: Firewalls Must Open UDP Ports
QUIC runs on UDP, but many server firewalls default to allowing TCP only. When deploying to cloud instances, make sure to open the WebTransport port (e.g., 4433) for UDP traffic in your security groups.
bash
# Example using iptables to allow UDP traffic on port 4433
iptables -A INPUT -p udp --dport 4433 -j ACCEPT
3. Project Structure and Dependencies
3.1 Installation
bash
# quic-go is the standard Go implementation of QUIC
go get github.com/quic-go/quic-go
# webtransport-go wraps WebTransport on top of quic-go
go get github.com/quic-go/webtransport-go
3.2 Directory Structure
We will structure our server into three decoupled layers:
internal/
└── wtserver/
├── server.go # Network: HTTP/3 listening & handshake upgrade
├── hub.go # Management: client registration, deregistration, & message routing
└── client.go # Connection: read/write loop for a single WebTransport session
server.gohandles network protocols.hub.gomonitors active sessions and handles message routing (no network APIs).client.gomanages the lifecycle of a single connection.
This design makes hub.go easily testable in unit tests without launching a real network server.
4. Network Layer: Initializing the Server (server.go)
go
package wtserver
import (
"context"
"crypto/tls"
"errors"
"net/http"
"github.com/quic-go/quic-go/http3"
"github.com/quic-go/webtransport-go"
"go.uber.org/zap"
)
// Server handles HTTP/3 listening and WebTransport handshake upgrades.
// It focuses purely on the network layer and does not store connection state.
type Server struct {
addr string
certFile string
keyFile string
wtServer *webtransport.Server
}
func NewServer(addr, certFile, keyFile string) *Server {
return &Server{
addr: addr,
certFile: certFile,
keyFile: keyFile,
}
}
// Upgrade upgrades HTTP/3 requests to WebTransport sessions.
func (s *Server) Upgrade(w http.ResponseWriter, r *http.Request) (*webtransport.Session, error) {
return s.wtServer.Upgrade(w, r)
}
// Start boots the HTTP/3 listener (blocking call).
func (s *Server) Start(ctx context.Context, handler http.Handler) error {
cert, err := tls.LoadX509KeyPair(s.certFile, s.keyFile)
if err != nil {
return err
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: []string{"h3"}, // Advertise HTTP/3 support
}
s.wtServer = &webtransport.Server{
H3: &http3.Server{
Addr: s.addr,
TLSConfig: tlsConfig,
Handler: handler,
},
CheckOrigin: func(r *http.Request) bool { return true },
}
// Critical step: inject WebTransport protocol extensions into the HTTP/3 server
webtransport.ConfigureHTTP3Server(s.wtServer.H3)
// Listen for context cancellation to execute graceful shutdown
go func() {
<-ctx.Done()
log.Info("WebTransport server shutting down...")
s.wtServer.Close()
}()
log.Info("WebTransport HTTP/3 server ready", zap.String("addr", s.addr))
err = s.wtServer.ListenAndServeTLS(s.certFile, s.keyFile)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
Key Details:
CheckOrigin: WebTransport enforces CORS policy similar to WebSockets. Returning true permits all origins (ideal for local testing). In production, configure an origin whitelist.
ConfigureHTTP3Server: This injects WebTransport negotiation parameters into the HTTP/3 handshake. Without this, WebTransport handshakes will fail silently.
5. Authentication: Why You Can't Reuse Gin Middlewares
If your app runs a standard REST API (e.g., Gin on port 8080) and WebTransport on port 4433, they run on completely separate listeners and HTTP versions. WebTransport runs on raw net/http handlers and cannot access Gin's routing tree.
Therefore, you must write a standard http.HandlerFunc middleware:
go
// middleware/wt_auth.go
// WTAuthMiddleware protects WebTransport handshakes
func WTAuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 1. Extract Token from URL query parameters
// Browsers cannot set custom headers during WebTransport handshakes,
// so query parameters are standard (just like WebSockets).
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 2. Verify token
claims, err := parseAndVerifyToken(token)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 3. Extract room_id (mandatory channel target)
roomIDStr := r.URL.Query().Get("room_id")
roomID, err := strconv.ParseUint(roomIDStr, 10, 64)
if err != nil || roomID == 0 {
http.Error(w, "invalid room_id", http.StatusBadRequest)
return
}
// 4. Extract source_ids (optional filters, comma-separated)
// Allows the client to filter incoming events
sourceIDsStr := r.URL.Query().Get("source_ids")
var sourceIDs []uint
if sourceIDsStr != "" {
for _, part := range strings.Split(sourceIDsStr, ",") {
if id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64); err == nil {
sourceIDs = append(sourceIDs, uint(id))
}
}
}
// 5. Propagate claims via standard Context
ctx := context.WithValue(r.Context(), "UserID", claims.UserID)
ctx = context.WithValue(ctx, "DeviceID", r.URL.Query().Get("device_id"))
ctx = context.WithValue(ctx, "RoomID", uint(roomID))
ctx = context.WithValue(ctx, "SourceIDs", sourceIDs)
next(w, r.WithContext(ctx))
}
}
⚠️ Security Warning: Query parameters are often logged in plaintext on server reverse proxies. If you need stricter security, use the query token solely for connection admission, and send the actual payload secrets in the first frames after the connection is established.
6. Connection Instance: Client Read/Write Separation
Each WebTransport session is wrapped in a Client struct:
go
// wtserver/client.go
type Client struct {
Session *webtransport.Session
UserID uint
DeviceID string
RoomID uint
SourceIDs []uint
RemoteAddr string
Send chan []byte
ctx context.Context
cancel context.CancelFunc
once sync.Once
}
Each client starts two goroutines: one for reading incoming data and one for writing outbound logs:
WebTransport Session
│
├── goroutine: Read() ←←← Listens for client pings/control commands
│
└── goroutine: Write() →→→ Pushes logs out of the Send channel
6.1 Safe Termination with sync.Once
When a connection terminates, both Read() and Write() will detect it and try to clean up. Multiple concurrent calls to close a session can cause errors or panics.
We use sync.Once to ensure the cleanup code runs exactly once:
go
func (c *Client) Close() {
c.once.Do(func() {
c.cancel() // Stop the counterpart goroutine
c.Session.CloseWithError(0, "closed")
})
}
6.2 The Read Loop
go
func (c *Client) Read(hub *Hub) {
defer func() {
c.Close()
hub.Unregister(c)
}()
for {
msg, err := c.Session.ReceiveDatagram(c.ctx)
if err != nil {
return // Triggered by client disconnect or context cancellation
}
_ = msg // Handle pings or subscription updates here
}
}
6.3 The Write Loop
go
func (c *Client) Write() {
defer c.Close()
for {
select {
case <-c.ctx.Done():
return
case msg, ok := <-c.Send:
if !ok {
return
}
// SendDatagram sends low-latency UDP packets (unreliable, drops allowed)
if err := c.Session.SendDatagram(msg); err != nil {
return
}
}
}
}
This symmetric lifecycle ensures that when either loop errors out, it calls Close(), which triggers context cancellation, causing the other loop to exit cleanly. This prevents goroutine leaks.
7. Connection Management: Designing the Hub
The Hub manages active connections using a nested map:
go
type Hub struct {
clients map[uint]map[string]*Client // key: RoomID, sub-key: DeviceID
lock sync.RWMutex
}
Why group by RoomID?
The most frequent operation is: "Broadcast message to all subscribers in Room X."
If we stored sessions in a flat map (e.g., keying by DeviceID), we would have to loop through every active client, checking their subscriptions on every message. This does not scale.
Keying by RoomID allows us to locate target client lists in O(1) time complexity, completely isolating traffic between rooms.
7.1 Register and Unregister
go
func (h *Hub) Register(c *Client) {
h.lock.Lock()
defer h.lock.Unlock()
if h.clients[c.RoomID] == nil {
h.clients[c.RoomID] = make(map[string]*Client)
}
h.clients[c.RoomID][c.DeviceID] = c
}
func (h *Hub) Unregister(c *Client) {
h.lock.Lock()
defer h.lock.Unlock()
devices, ok := h.clients[c.RoomID]
if !ok {
return
}
delete(devices, c.DeviceID)
// Delete empty maps to prevent memory leaks over long runtimes
if len(devices) == 0 {
delete(h.clients, c.RoomID)
}
}
7.2 Safe Broadcasting: BroadcastToRoom
Here is the core routing method:
go
func (h *Hub) BroadcastToRoom(roomID uint, sourceID uint, msg []byte) bool {
// 1. Acquire read lock and locate target room
h.lock.RLock()
devices, ok := h.clients[roomID]
if !ok || len(devices) == 0 {
h.lock.RUnlock()
return false
}
// 2. Gather matching targets inside the lock
// ⚠️ CRITICAL: Do NOT write to channels inside the lock!
targets := make([]*Client, 0, len(devices))
for _, c := range devices {
if len(c.SourceIDs) > 0 {
matched := false
for _, id := range c.SourceIDs {
if id == sourceID {
matched = true
break
}
}
if !matched {
continue
}
}
targets = append(targets, c)
}
// 3. Release lock immediately after pointer collection
h.lock.RUnlock()
// 4. Push to channels outside the lock
sent := 0
for _, c := range targets {
select {
case c.Send <- msg:
sent++
default:
// Channel full (slow client). Skip non-blockingly using default
}
}
return sent > 0
}
Why collect pointers first and push outside the lock?
If we executed c.Send <- msg inside the read lock, a single slow client with a full buffer would block the channel write.
Since this write occurs inside the lock, the read lock remains open indefinitely. This blocks all other broadcast attempts to all rooms across the server.
By collecting pointers under a brief lock hold and executing channel pushes outside the lock, a slow client only drops its own packets without affecting other active streams.
8. Connection Handshake Handler
After passing the middleware, the upgraded connection is handed over to the HTTP route:
go
func Connect(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("UserID").(uint)
deviceID, _ := r.Context().Value("DeviceID").(string)
roomID := r.Context().Value("RoomID").(uint)
sourceIDs := r.Context().Value("SourceIDs").([]uint)
// Upgrade request to WebTransport Session
session, err := WTSrv.Upgrade(w, r)
if err != nil {
log.Error("Handshake upgrade failed", zap.Error(err))
return
}
client := NewClient(session, userID, deviceID, roomID, sourceIDs, r.RemoteAddr)
MainHub.Register(client)
// Spin up write loop asynchronously
go client.Write()
// Block on read loop until connection is closed
client.Read(MainHub)
}
The blocking client.Read call will stay open. Once it terminates, the defer block triggers Close and Unregister, cleaning up resources cleanly.
9. Lifecycle Management with Errgroup
To run both REST (port 8080) and WebTransport (port 4433) in the same process and coordinate their lifecycles, use errgroup:
go
func Run() {
MainHub = NewHub()
WTSrv = NewServer(":4433", "./certs/cert.pem", "./certs/key.pem")
wtMux := http.NewServeMux()
wtMux.HandleFunc("/stream", WTAuthMiddleware(Connect))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
eg, egCtx := errgroup.WithContext(ctx)
// REST Server
eg.Go(func() error {
return runRESTServer(egCtx)
})
// WebTransport Server
eg.Go(func() error {
return WTSrv.Start(egCtx, wtMux)
})
if err := eg.Wait(); err != nil {
log.Error("Service exited with error", zap.Error(err))
}
}
If either server crashes or stops, egCtx cancels, signaling the remaining server to shut down cleanly instead of leaving behind a zombie process.
10. Overall Data Flow Review
Putting all the pieces together, here is the complete data flow of a message from ingestion to browser client reception:
Ingestion Trigger (e.g., HTTP Ingest API)
│
▼
Push Ingestion Handler
├── Authenticate token
├── Write to database (asynchronous batching)
└── Call MainHub.BroadcastToRoom(roomID, sourceID, jsonBytes)
│
▼
Hub.BroadcastToRoom
├── Read Lock: Locate target room group (O(1))
├── Filter by SourceIDs
├── Gather matching target pointers, release read lock
└── Outside Lock: Non-blockingly push to Client.Send channel
│
▼
Client.Write goroutine
└── Session.SendDatagram(msg)
│
▼
Browser Client (Real-time receive)
Summary
By building this architecture, we have achieved:
| Feature | Solution |
|---|---|
| Transport | WebTransport Datagrams (UDP/QUIC) |
| Handshake Auth | Raw http.HandlerFunc middleware using query parameters |
| Room Sharding | Dual map key sharding by RoomID (O(1) complexity) |
| Target Filtering | Whitelisting source_ids on the connection metadata |
| Safe Shutdown | Symmetrical loops wrapped in sync.Once and context |
| Thread Safety | Gathering pointers inside RLock, pushing channels outside |
| Process Sync | Coordinating REST and HTTP/3 runtimes via errgroup |
WebTransport is a great fit for real-time pushing services. Hopefully, this guide helps you avoid the common pitfalls and start streaming!