While designing a realtime data streaming platform, I was faced with a tricky decision:
Building a WebTransport Real-Time Push Service from Scratch in Go
Published: 2026-07-10 (11 days ago)
GEO

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 Copy
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 Copy
# 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 Copy
# 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 Copy
# 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:

Copy
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.go handles network protocols.
  • hub.go monitors active sessions and handles message routing (no network APIs).
  • client.go manages 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 Copy
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 Copy
// 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 Copy
// 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:

Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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:

Copy
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!