Skip to content

    System Design

    HTTP/1.1, HTTP/2 and HTTP/3 on the Server: Connections, Streams, QUIC

    What the three HTTP versions mean for servers: keep-alive, stream limits, head-of-line blocking, QUIC over UDP, and the nginx and Envoy settings that tune them.

    By Published 13 min read

    Share

    Standard lessonintermediateBackend System Design

    Before this: TCP and UDP (coming soon), TLS and HTTPS (coming soon)

    What you'll learn: By the end of this lesson you will be able to explain what a server pays for in each HTTP version (connections in HTTP/1.1, streams on one connection in HTTP/2, QUIC streams over UDP in HTTP/3), describe persistent connections and why an idle-timeout race produces rare errors, state what the stream limit and flow-control window do, say where head-of-line blocking still happens, read the nginx and Envoy settings that tune each version, and choose which version each hop of a request path should speak.

    A warehouse has a loading dock. In the old arrangement each lorry occupies a bay until its single parcel is unloaded, so the dock handles as many parcels at once as it has bays. In the next, one lorry arrives with many parcels from different customers and the crew unloads them interleaved, but if the lorry's door jams, every parcel inside waits. In the third, the parcels travel in separate vans, so a breakdown holds back only the van that broke down.

    The three HTTP versions are those arrangements, seen from the dock. The browser's side, such as why domain sharding is now a bad habit, is in HTTP/1.1, HTTP/2 and HTTP/3 for Front-End Engineers. This lesson is the server side: what you configure, what you pay for and what breaks. It assumes you know the request lifecycle and have met a load balancer.

    Verified against RFC 9110, 9112, 9113, 9114 and 9000, the nginx documentation (core, HTTP/2, HTTP/3, upstream, SSL modules) and the Envoy documentation ("latest" pages), all read on 2026-11-08. Defaults change between releases, so check the page for the version you run.

    The core idea

    The meaning of an HTTP message is the same in every version; the cost of carrying it is not. Methods, status codes and fields are defined once (RFC 9110). RFC 9114 says it in one line: "HTTP/3 supports the same semantics over a new transport protocol: QUIC." What differs for a server is which resource runs out first:

    1. HTTP/1.1: connections. One request is outstanding per connection, so concurrency means many connections, each with its own memory and file descriptor.
    2. HTTP/2: streams on a connection. Many requests share one TCP connection, so the limits become the stream count and the flow-control windows, and one lost TCP segment stalls all of them.
    3. HTTP/3: QUIC streams over UDP. Many requests share one QUIC connection that recovers loss per stream, at the price of a UDP path to open and a younger server implementation.

    The illustrations are a minimal example (connection arithmetic), a realistic example (ShopStream's edge) and a failure example (a full stream limit).

    HTTP/1.1: a connection is the unit of concurrency

    RFC 9112 section 9.3 says HTTP/1.1 "defaults to the use of persistent connections": a connection stays open after a response unless someone sends the close option. Inside the connection the rule is unchanged, one exchange at a time. Pipelining (sending the next request before the response arrives) exists, but the server must answer "in the same order that the requests were received", so it still blocks behind a slow response, and a user agent "SHOULD NOT pipeline requests after a non-idempotent method".

    A busy HTTP/1.1 service therefore holds one connection per request in flight plus idle connections kept for reuse, and servers cap both. nginx closes a client connection after keepalive_timeout (default 75s), after keepalive_requests requests (1000 since 1.19.10, 100 before) or after keepalive_time (1h). The upstream module explains why: "closing connections periodically is necessary to free per-connection memory allocations."

    The idle-connection race

    RFC 9112 section 9.5 admits a timing problem. A server has "a timeout value beyond which they will no longer maintain an inactive connection", and a "client, server, or proxy MAY close the transport connection at any time", so a client "might have started to send a new request at the same time that the server has decided to close". Section 9.3.1 tells implementations to "anticipate the need to recover from asynchronous close events".

    sequenceDiagram accTitle: The idle keep-alive race between a proxy and an upstream accDescr: The upstream closes an idle connection at the moment the proxy sends a new request on it. The request reaches a closed connection and the proxy returns a gateway error although the upstream was healthy. participant C as Client participant P as Proxy participant U as Upstream app P->>U: request 1 on connection A U-->>P: response 1, connection A stays open Note over U: idle timeout fires, upstream closes A Note over P: proxy has not yet noticed C->>P: request 2 P->>U: request 2 on connection A U--xP: connection closed (RST or EOF) P-->>C: 502 Bad Gateway

    Figure 1. A rare 502 from a healthy upstream. How to read this: both sides acted correctly. The upstream closed an idle connection and the proxy reused one it did not yet know was closed. The defence is to make the proxy drop idle connections before the upstream does, and to retry only what is safe.

    HTTP/2: streams on one connection

    HTTP/2 (RFC 9113) keeps the semantics and changes the packaging. Each request and response is a stream, and messages are cut into frames that interleave on one connection, so a slow response no longer holds up the next. The server-side numbers:

    Setting or rule What RFC 9113 says Server-side effect
    SETTINGS_MAX_CONCURRENT_STREAMS Initially no limit; "recommended that this value be no smaller than 100". Endpoints "MUST NOT exceed the limit set by their peer" Caps the work one client connection can have in flight. nginx defaults to 128
    Flow-control window "65,535 octets for both new streams and the overall connection" A slow reader cannot make you buffer without bound
    TLS h2 is the ALPN identifier; "MUST use TLS version 1.2 or higher" The version is chosen inside the TLS handshake
    GOAWAY Starts closing; "best-effort" The graceful way to drain a connection during a deploy

    If one connection carries 128 requests at once, a thread-per-request server may serialise them; check how your framework maps streams to threads before raising the limit.

    What HTTP/2 does not fix

    RFC 9113's introduction says "TCP head-of-line blocking is not addressed by this protocol." TCP delivers bytes in order, so one lost segment holds back everything behind it, for every stream on that connection, until the retransmission arrives. Streams are independent in HTTP/2's accounting but not on the wire. On a clean network you do not notice; on a lossy mobile path many streams stall together. Section 10.5 adds: "Both field section compression and flow control depend on a commitment of a greater amount of state." That is is why servers cap per-connection work.

    sequenceDiagram accTitle: HTTP/2 stream limit on one connection accDescr: The server announces a limit of 128 concurrent streams. The client opens 128 requests. A further request must wait until one stream finishes, even though the connection is healthy. participant C as Client participant S as Server S->>C: SETTINGS max concurrent streams = 128 C->>S: 128 requests open at once Note over C: a new request must wait, the limit is reached S-->>C: response on one stream, the stream closes C->>S: the waiting request now opens a stream

    Figure 2. The stream limit is a queue, not an error. How to read this: at the limit the client waits locally and the server sees nothing, so saturation shows up as latency, not errors. Envoy describes the same for its upstream pool: a connection at the limit "will be marked as busy until a stream is available".

    HTTP/3: QUIC over UDP

    HTTP/3 (RFC 9114) runs on QUIC (RFC 9000, "A UDP-Based Multiplexed and Secure Transport"), which provides streams, loss recovery and TLS 1.3 itself. RFC 9000 says "QUIC does not provide any means of ensuring ordering between bytes on different streams", so one lost packet delays only the streams whose data was in it. Header compression becomes QPACK, because HPACK needs in-order delivery. For operations:

    • A UDP port. Firewalls, security groups and layer 4 balancers must pass UDP, usually 443. A blocked path is safe for users: RFC 9114 says clients "SHOULD attempt to use TCP-based versions of HTTP in this case".
    • Discovery. Clients learn about HTTP/3 from Alt-Svc. nginx's page adds it by hand: add_header Alt-Svc 'h3=":8443"; ma=86400';. Envoy's page says internet-facing deployments need it, because "clients such as Chrome will only attempt HTTP/3 if it is explicitly advertised".
    • Idle timeout. Each side advertises one, and "the effective value at an endpoint is computed as the minimum of the two advertised values" (RFC 9000 section 10.1).
    • Address validation. Before a client's address is validated, "servers MUST NOT send more than three times as many bytes as the number of bytes they have received" (section 8.1).
    • Migration. RFC 9000 section 9 says "the use of a connection ID allows connections to survive changes to endpoint addresses (IP address and port)", so a phone moving from Wi-Fi to cellular can keep its connection. nginx documents quic_bpf (Linux 5.7 or later, default off) as routing QUIC packets with eBPF, which "allows supporting QUIC connection migration".
    • Maturity. nginx's page calls its HTTP/3 module "experimental", not built by default, and says 0-RTT needs OpenSSL 3.5.1 or later or another library. Read your own server's status.

    nginx's ssl_early_data (0-RTT) is off by default, and the page says such requests "are subject to replay attacks".

    Each hop has its own version

    A request path is several connections in series, and each negotiates its own version. Envoy has "native support for HTTP/1.1, HTTP/2 and HTTP/3" and makes HTTP/1.1 look "like HTTP/2 to higher layers": routing rules do not see the difference, capacity planning does.

    One request path, three different connectionsClient to Edge proxy (h3, h2 or h1.1); Edge proxy to Service proxy (h2, long-lived); Service proxy to App servers (h1.1 keep-alive)PUBLIC INTERNETYOUR NETWORKClient: browser or appClientbrowser or appEdge proxy: terminates TLS or QUICEdge proxyterminates TLS orQUICService proxy: pools connectionsService proxypools connectionsApp servers: statelessApp serversstatelessh3, h2 or h1.1h2, long-livedh1.1 keep-alive

    Figure 3. Version per hop is a design decision. How to read this: the client picks what it supports and the edge negotiates the best match. The inside hops are yours, and your choice decides how connections are pooled and how failure spreads. The labels are one plausible arrangement.

    Pooling is where tuning pays off. In nginx's upstream, keepalive caches idle upstream connections per worker (default 32 local since 1.29.7) and "does not limit the total number of connections to upstream servers that an nginx worker process can open". Envoy's pools are per worker thread and drain a connection on GOAWAY.

    Concern nginx setting Default
    Request header and body timeouts client_header_timeout, client_body_timeout 60s each (408 on timeout)
    Body size client_max_body_size 1m (413)
    HTTP/2 and HTTP/3 streams http2_max_concurrent_streams, http3_max_concurrent_streams 128 each
    QUIC retry, 0-RTT quic_retry, ssl_early_data off, off

    Trade-offs

    Version What it buys you What it costs the server Watch for
    HTTP/1.1 Simple, universal, easy to debug One connection per request in flight; many idle sockets Idle-timeout races, parsing differences between hops
    HTTP/2 Many requests per connection, compressed headers Stateful compression, stream and window accounting, TLS TCP head-of-line blocking; a full stream limit looks like latency
    HTTP/3 Per-stream loss recovery, faster set-up, migration A UDP path to own, a younger server module, user-space CPU Blocked UDP, stale Alt-Svc, 0-RTT replay

    Three illustrations

    A minimal example (the arithmetic). Take ShopStream's shared browsing peak of about 2,300 requests per second and suppose, illustratively, 50 ms per request at the app. Requests in flight are 2,300 x 0.05 = 115. An HTTP/1.1 upstream connection carries one at a time, so at least 115 connections are busy at the peak. With HTTP/2 upstream and a 128-stream limit, they could share one connection per worker with traffic. HTTP/1.1 scales connections with concurrency; HTTP/2 scales streams.

    A realistic example (ShopStream's edge). ShopStream's read path has a layer 7 front door that speaks HTTP/2 to shoppers and HTTP/1.1 with keep-alive to the app servers. "In practice" shows how HTTP/3 is added.

    A failure or edge case (a full stream limit). A slow store-locator call waits 20 seconds on an overloaded dependency. The service proxy sends everything to one app instance over a single HTTP/2 connection limited to 128 streams. Slow calls fill the limit, fast calls queue behind them, and the instance has CPU to spare. Nothing errors; p99 latency climbs. Fixes: a per-route timeout, a separate pool for slow routes (a bulkhead, see Failure Handling End to End), and load shedding.

    The other side of this topic. The browser decides what it speaks and discovers HTTP/3 through Alt-Svc; the front-end lesson covers that. Everything above is what the server must provide for it to work.

    In practice: turning on HTTP/3 at ShopStream's edge

    NOTE

    This is a realistic scenario, not a case history. It is a composite on the course's running example, ShopStream. It describes no real company, and every number other than the shared ShopStream figures is an illustrative assumption.

    The situation

    ShopStream's shoppers are mostly on phones, and product pages are slow on weak connections. The edge proxy serves HTTP/2. A teammate proposes HTTP/3 because QUIC recovers loss per stream.

    Starting architecture

    ShopStream edge today: HTTP/2 from shoppers, HTTP/1.1 to the app tierShoppers to Edge proxy (TCP 443); Edge proxy to App server 1 (h1.1 keep-alive); Edge proxy to App server 2 (h1.1 keep-alive)Shoppers: mostly mobileShoppersmostly mobileEdge proxy: TLS, h2Edge proxyTLS, h2App server 1App server 1App server 2App server 2TCP 443h1.1 keep-aliveh1.1 keep-alive

    Figure 4. Before the change. How to read this: only the shopper-facing hop uses HTTP/2. The inside hop is pooled HTTP/1.1, so capacity is a question of idle and busy connections per app server.

    Options considered

    Option Benefit Cost Risk
    A. Stay on HTTP/2 No change Loss still stalls all streams Missed gain
    B. HTTP/3 beside HTTP/2, short ma Capable browsers try QUIC; others stay on TCP A UDP rule to own, an edge build with HTTP/3 Blocked UDP, 0-RTT replay
    C. Also HTTP/2 to the app Fewer upstream connections App must handle streams A full stream limit (Figure 2)

    The decision

    Option B, 0-RTT off, a short ma, a field metric by protocol. Option C waits for evidence that upstream connection count is a problem. Record: context mobile loss; decision HTTP/3 beside HTTP/2, UDP 443 opened and monitored; consequences a firewall rule and a protocol dimension on dashboards; revisit when field data shows no gain.

    Rollout and verification

    1. Baseline mobile latency percentiles and the protocol share (nginx logs $http3).
    2. Enable on a few edge nodes with a short ma, changing Alt-Svc and the UDP rule together.
    3. Compare latency by protocol; roll back with Alt-Svc: clear if nothing improves.

    The 2 a.m. failure

    The page reads: about one 502 in a few thousand requests from the edge at low traffic, never on retry. Checks: do errors fall on reused upstream connections; compare the app's idle timeout with the edge's upstream keep-alive timeout (nginx's upstream default is 60s); look for resets in the app logs. This is Figure 1. Mitigation: make the edge's upstream timeout shorter than the app's idle timeout. Follow-up: retry only idempotent requests (nginx does not retry non-idempotent methods by default) and alert on upstream reset rate. HTTP/3 was not the cause; compare both timeouts whenever either hop changes.

    Architect's notes

    • Set the version per hop, deliberately, with the stream or connection limit that goes with it.
    • The side holding the idle connection should give up first. The proxy's idle timeout must be shorter than the upstream's.
    • Alert on queueing, not only on errors.
    • Own the UDP path. Keep Alt-Svc and the firewall rule in one reviewed change, and check your server's HTTP/3 status at each upgrade.
    flowchart TD accTitle: Choosing the HTTP version for a hop accDescr: A decision flow. A hop facing clients on lossy networks that allows UDP offers HTTP/3 beside HTTP/2. An internal hop with many parallel requests and an event-driven server uses HTTP/2. Otherwise use HTTP/1.1 keep-alive with matched idle timeouts. S["A hop in the request path"] --> Q1{"Faces clients on lossy networks, and UDP can pass?"} Q1 -->|"yes"| H3["Offer HTTP/3 beside HTTP/2, advertise with Alt-Svc, keep TCP fallback"] Q1 -->|"no"| Q2{"Many parallel requests, and a server that handles streams well?"} Q2 -->|"yes"| H2["HTTP/2 with a stream limit and per-route timeouts"] Q2 -->|"no"| H1["HTTP/1.1 keep-alive, matched idle timeouts, pooled connections"]

    Figure 5. A starting rule per hop. How to read this: it is a default to argue with. Ask the questions for each hop; the answer for a public edge usually differs from an internal hop.

    Common mistakes

    • Mismatched idle timeouts, giving sporadic 502s.
    • Assuming HTTP/2 fixes loss. TCP head-of-line blocking is not addressed by the protocol.
    • Advertising HTTP/3 without owning the UDP path.
    • Enabling 0-RTT, or retrying non-idempotent requests, after a closed connection.

    Key takeaways

    • The semantics are shared; the transport, and so your resource limits, are not.
    • HTTP/1.1 scales with connections, HTTP/2 with streams, HTTP/3 with QUIC streams over UDP.
    • Idle-timeout races are normal on persistent connections; design for them.
    • A full stream limit is a queue the server does not see.
    • HTTP/2 leaves TCP head-of-line blocking; QUIC avoids it across streams.
    • Each hop negotiates its own version: choose per hop and tune pools per worker.

    Practice exercises

    Exercise 1 (20 min). Repeat the arithmetic for 200 ms per request at 2,300 requests per second. How many HTTP/1.1 connections are busy, and does one HTTP/2 connection with a 128-stream limit suffice?

    Exercise 2 (30 min). The edge has an upstream keep-alive timeout of 60s and the app closes idle connections after 30s. Explain the Figure 1 race and name the cheapest fix.

    Exercise 3 (1 hour). Write a rollout plan for HTTP/3 on a service you know: the UDP rule, the Alt-Svc value, the metric by protocol and the rollback step.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments