Serving SSE over HTTP/2 with nginx Permalink to this section

Part of HTTP/2 and HTTP/3 for Event Streams, under SSE Protocol Fundamentals & Architecture.

Moving a stream from HTTP/1.1 to HTTP/2 is a two-line nginx change and a one-line application change, and getting either wrong produces failures that look nothing like a protocol problem. This page is the exact configuration, in the order you should apply it.

Symptom & Developer Intent Permalink to this section

The behaviours that bring people here:

  • A user with several tabs open reports that the whole site stops loading — images pending, API calls queued — while the streams keep working.
  • chrome://net-internals or the Network panel shows requests stuck in a Queueing state against one origin.
  • The Protocol column in DevTools shows http/1.1 for the stream even though the rest of the site is h2.
  • After enabling HTTP/2, the stream now fails immediately with a connection reset and an empty error in the console.

The intent is to serve the same text/event-stream endpoint over HTTP/2 so it stops consuming one of the browser’s six per-origin connections, without changing the application’s streaming code.

Root Cause Analysis Permalink to this section

Two distinct causes hide behind these symptoms.

Two protocol legs, one stream The browser speaks HTTP/2 to nginx while nginx speaks HTTP/1.1 to the application, with the settings each leg needs. Two protocol legs, one stream the 1.0 default on the upstream leg disables chunked transfer and forces buffering Browser h2, multiplexed nginx http2 on; TLS Application one request, one stream TLS + ALPN proxy_http_version 1.1
Terminating HTTP/2 at the proxy gives the browser multiplexing while the application keeps the simple model it already handles — provided the upstream leg is 1.1, not 1.0.

The first is the HTTP/1.1 per-origin connection limit. Browsers open at most six TCP connections per origin, and an SSE response never completes, so each open stream holds one permanently. With two streams per tab and three tabs, all six are consumed and every other request to that origin queues behind them. Nothing errors — the requests simply wait — which is why this presents as “the site is slow” rather than as a failure.

The second is protocol violation. HTTP/2 forbids the hop-by-hop headers HTTP/1.1 relies on: Connection, Keep-Alive, Transfer-Encoding, Upgrade and Proxy-Connection. An application that sets Connection: keep-alive on its SSE response — advice repeated in many tutorials — emits a protocol error the moment nginx serves that response over HTTP/2. The browser reports a bare net::ERR_HTTP2_PROTOCOL_ERROR with no indication of which header caused it.

A third, quieter cause explains streams that stay on HTTP/1.1 after the change: nginx’s proxy_http_version defaults to 1.0 on the upstream leg. That does not affect the browser-facing protocol, but it disables chunked transfer to the upstream and forces buffering — so the stream downgrades in behaviour even when the front-end negotiation is correct.

Step-by-Step Resolution Permalink to this section

The directives that matter, and what each one prevents Table of nginx directives for a streaming location with the failure each one prevents. The directives that matter, and what each one prevents Set to Prevents proxy_http_version 1.1 forced buffering proxy_set_header Connection "" illegal header upstream proxy_buffering off burst delivery gzip off compressor batching proxy_read_timeout 3600s death at 60 s
Four lines carry the whole configuration; the rest of the location block is ordinary proxying.

Step 1 — Remove hop-by-hop headers from the application Permalink to this section

Before touching nginx, make the response legal under both protocol versions. The only headers an SSE response needs are the content type and the caching directives.

// Express — the corrected header set.
res.writeHead(200, {
  'Content-Type': 'text/event-stream; charset=utf-8',
  'Cache-Control': 'no-cache, no-transform',
  'X-Accel-Buffering': 'no',
  // 'Connection': 'keep-alive',   ← remove: illegal over HTTP/2, unnecessary over 1.1
});
res.flushHeaders();

Grep the codebase for the header rather than trusting one route: middleware and framework defaults set it in surprising places.

grep -rn "Connection.*keep-alive" --include='*.js' --include='*.ts' --include='*.go' --include='*.py' src/

Step 2 — Enable HTTP/2 on the listener Permalink to this section

Modern nginx uses a separate http2 directive rather than a parameter on listen. HTTP/2 requires TLS in practice, because no browser negotiates it in cleartext.

server {
    listen 443 ssl;
    http2 on;                       # nginx 1.25.1+ syntax

    server_name api.example.com;
    ssl_certificate     /etc/ssl/certs/api.pem;
    ssl_certificate_key /etc/ssl/private/api.key;
}

Step 3 — Configure the streaming location Permalink to this section

The upstream leg stays HTTP/1.1. The three critical directives are proxy_http_version, the emptied Connection header, and disabled buffering.

location /events {
    proxy_pass http://sse_backend;

    proxy_http_version 1.1;          # NOT the 1.0 default — enables chunked upstream
    proxy_set_header Connection "";  # strip the client's hop-by-hop header

    proxy_buffering off;             # never accumulate the response
    proxy_cache off;
    gzip off;                        # a compressor must buffer to work

    proxy_read_timeout 3600s;        # far above the heartbeat interval
    proxy_send_timeout 3600s;

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Step 4 — Raise the concurrent stream limit if clients open many streams Permalink to this section

HTTP/2 multiplexes, but not without bound. nginx advertises 128 concurrent streams per connection by default, and browsers queue beyond that rather than erroring.

http {
    http2_max_concurrent_streams 256;   # only if clients legitimately open many streams
    keepalive_requests 100000;          # do not recycle a connection mid-stream
}

Step 5 — Reload and verify the negotiated protocol Permalink to this section

nginx -t && systemctl reload nginx

# The stream URL specifically — routing often differs per path.
curl -so /dev/null -w 'protocol: %{http_version}\n' https://api.example.com/events
# protocol: 2

# No illegal hop-by-hop headers survive.
curl -sI --http2 https://api.example.com/events | grep -iE 'connection|transfer-encoding|upgrade'
# (no output)

Validation & Monitoring Permalink to this section

Confirm the three properties that matter — protocol, immediacy and longevity — in that order.

Three checks, in order Verification sequence confirming the negotiated protocol, immediate first byte and the absence of hop-by-hop headers. Three checks, in order curl nginx App -w '%{http_version}' → 2 GET, HTTP/1.1 upstream first frame immediately no Connection / Transfer-Encoding
Run all three against the stream URL specifically — routing rules frequently differ from the origin root.
# 1. Protocol negotiated for the stream itself.
curl -so /dev/null -w '%{http_version}\n' https://api.example.com/events

# 2. First byte arrives immediately (no buffering).
curl -N -s -o /dev/null -w 'first byte: %{time_starttransfer}s\n' https://api.example.com/events
# Expect well under 1s. Seconds means something is still buffering.

# 3. An idle stream survives past the old timeout.
timeout 180 curl -N -s https://api.example.com/events | head -c 200

In the browser, add the Protocol column to the Network panel and confirm the stream row shows h2. Then open six tabs of the application: under HTTP/1.1 the seventh request to that origin would queue indefinitely, while under HTTP/2 everything continues to load normally. That test is the reason for the change, so it is worth doing explicitly.

For ongoing signal, record the negotiated protocol per connection server-side and report nextHopProtocol from the client:

// Server: log the version so the real mix is visible, not assumed.
logger.info({ event: 'sse_open', http_version: req.httpVersion });

// Client: what the browser actually used, including any corporate downgrade.
const entry = performance.getEntriesByType('resource').find((r) => r.name.includes('/events'));
beacon('sse_protocol', entry?.nextHopProtocol);   // "h2" | "h3" | "http/1.1"

A meaningful share of http/1.1 in field data after this change is normal: TLS-inspecting corporate proxies downgrade everything, and those users still need the connection-count discipline described in Connection-Count Trade-offs: SSE vs WebSockets.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Why does my stream fail with ERR_HTTP2_PROTOCOL_ERROR after enabling HTTP/2?

A hop-by-hop header that is legal over HTTP/1.1 and illegal over HTTP/2 — almost always Connection: keep-alive set by the application. Remove it from every route that streams; it was never doing anything useful over HTTP/1.1 either, since persistent connections are the default there.

Does the upstream connection between nginx and my app also need to be HTTP/2?

No, and it usually should not be. Terminate HTTP/2 at nginx and speak HTTP/1.1 upstream with proxy_http_version 1.1: the browser gets multiplexing while the application keeps the simple one-request-per-connection model. What matters is that the upstream leg is 1.1 rather than the 1.0 default, because 1.0 disables chunked transfer and forces buffering.

How many streams can one browser hold now?

Up to nginx's advertised http2_max_concurrent_streams, which defaults to 128 per connection — against six total under HTTP/1.1. Browsers queue streams beyond the limit rather than failing them, so exhaustion looks like a hang. That is still far more headroom than most applications need, but it is not unlimited.

Do I need to change any client code?

None. EventSource uses whichever protocol the browser negotiated during the TLS handshake, and there is no API to select or read it directly. Read performance.getEntriesByType('resource')[…].nextHopProtocol if you want to record which version a real user actually got.