How Hysteria Core Handles Concurrent Connections: QUIC, Goroutines, and Session Management

Hysteria manages concurrent connections through a layered goroutine architecture that spawns dedicated threads for each QUIC connection and individual TCP streams, while using sync.RWMutex and per-session sync.Mutex to protect shared UDP session state.

The apernet/hysteria repository implements a high-throughput proxy core built on QUIC (HTTP/3) that scales to thousands of simultaneous flows. Rather than using thread pools or complex schedulers, the codebase embraces Go’s goroutine-per-connection model with strategic synchronization primitives to ensure thread safety without sacrificing performance.

Connection-Level Concurrency: The QUIC Listener

At the entry point, serverImpl.Serve in core/server/server.go runs an infinite acceptance loop. Each time the QUIC listener accepts a new connection, the server immediately delegates handling to a new goroutine.

This isolation ensures that a single client’s QUIC session operates independently:

func (s *serverImpl) Serve() error {
    for {
        conn, err := s.listener.Accept(context.Background())
        if err != nil { return err }
        go s.handleClient(conn)   // ← new goroutine per client
    }
}

The handleClient method then initializes an HTTP/3 server instance with a custom StreamDispatcher, creating a dedicated execution context for all streams within that connection.

Stream-Level Parallelism for TCP Traffic

Inside handleClient, Hysteria hijacks incoming HTTP/3 streams via h3sHandler.ProxyStreamHijacker. Rather than processing streams sequentially, the dispatcher spawns a new goroutine for every TCP request:

func (h *h3sHandler) ProxyStreamHijacker(ft http3.FrameType, stream *quic.Stream, err error) (bool, error) {
    if ft == protocol.FrameTypeTCPRequest && err == nil && h.authenticated {
        qStream := &utils.QStream{Stream: stream}
        go h.handleTCPRequest(qStream) // ← concurrent handling per stream
        return true, nil
    }
    return false, nil
}

This design allows dozens of TCP streams to proxy simultaneously over a single QUIC connection without head-of-line blocking. The goroutine-per-stream model matches Hysteria’s goal of maximizing throughput for bursty traffic patterns.

UDP Session Architecture

UDP traffic follows a different concurrency pattern. Instead of per-packet goroutines, Hysteria uses a centralized udpSessionManager instantiated in core/server/udp.go.

The Manager Loop

When UDP is enabled, handleClient starts the manager’s Run loop in its own goroutine:

func (m *udpSessionManager) Run() error {
    stopCh := make(chan struct{})
    go m.idleCleanupLoop(stopCh) // ← cleanup runs in its own goroutine
    defer close(stopCh)
    for {
        msg, err := m.io.ReceiveMessage()
        if err != nil { return err }
        m.feed(msg) // feed updates/creates per‑session entries
    }
}

This loop receives all UDP datagrams for the connection and routes them to appropriate session entries based on SessionID.

Per-Session State and Cleanup

Each UDP session is represented by a udpSessionEntry stored in a protected map:

  • Shared state protection: The map map[uint32]*udpSessionEntry is guarded by a sync.RWMutex. Lookups during active traffic acquire read locks, while new session creation requires write locks.
  • Per-session isolation: Each entry owns its own UDP connection and runs receiveLoop in a dedicated goroutine.
  • Race prevention: A per-entry sync.Mutex (connLock) guarantees that a connection cannot be created after the session has been closed.
  • Resource reclamation: An idleCleanupLoop runs every second to remove stale sessions based on configurable timeouts, preventing memory leaks from abandoned UDP flows.

Synchronization Strategy

Hysteria’s concurrency model distributes locks across layers to minimize contention:

Layer Primitive Purpose
Connection acceptance Goroutine per connection (handleClient) Isolates each client’s QUIC session
TCP streams Goroutine per request (handleTCPRequest) Enables parallel proxying without blocking
UDP session map sync.RWMutex Allows concurrent reads (traffic) with exclusive writes (new sessions)
UDP session entry sync.Mutex (connLock) Prevents races during connection setup and teardown
Client-side Identical patterns core/client/client.go mirrors server concurrency logic for bidirectional consistency

Summary

  • Hysteria accepts QUIC connections in core/server/server.go and immediately spawns a goroutine per connection via go s.handleClient(conn).
  • Each TCP stream within a connection runs concurrently through go h.handleTCPRequest(qStream), preventing head-of-line blocking.
  • UDP traffic uses a centralized udpSessionManager with a goroutine-per-session model and sync.RWMutex for the session map to optimize read-heavy workloads.
  • Per-session sync.Mutex locks and a dedicated idleCleanupLoop ensure safe resource cleanup and prevent race conditions during session termination.

Frequently Asked Questions

How many goroutines does Hysteria create per client connection?

Hysteria creates one goroutine to handle the overall QUIC connection, plus an additional goroutine for every active TCP stream via handleTCPRequest. For UDP traffic, it spawns one goroutine per UDP session entry to manage read/write loops independently.

Why does Hysteria use sync.RWMutex specifically for UDP sessions?

The sync.RWMutex in core/server/udp.go optimizes for read-heavy access patterns. Since normal traffic requires frequent lookups in the session map while new session creation is relatively rare, read locks allow multiple goroutines to query the map simultaneously without blocking each other.

What prevents race conditions when closing UDP sessions?

Each udpSessionEntry maintains its own sync.Mutex (the connLock field) to serialize connection creation and closure operations. Additionally, the idleCleanupLoop checks atomic timestamps before removing entries, ensuring that active sessions are never terminated mid-transmission.

Does Hysteria reuse goroutines or create new ones for each stream?

Hysteria creates new goroutines for each TCP stream rather than using a fixed worker pool. This approach leverages Go’s efficient scheduler and minimizes latency by eliminating queueing overhead, which is critical for maintaining low-latency proxy performance under high concurrency.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →