Chromium · HTTP/3 · RFC 9114 §4.1
HTTP/3 responses with trailers never finish in Chromium
Fixed, not yet released First in Chromium 160.0.8158.0In Canary; Stable is 158.0.8127.37
Chromium decodes the response headers, the DATA frame and the trailing HEADERS frame of an HTTP/3 response, then fails the body read with net::ERR_CONNECTION_CLOSED (-100). The request fails, and a navigation to the response never reaches load. The same response without trailers loads normally.
It fails with and without content-length, with and without a Trailer header field, and against two unrelated servers (aioquic 1.7.2 and a quinn-based one). Nothing on the wire is malformed: RFC 9114 §4.1 allows a trailing field section after the content, Chromium sends no STOP_SENDING, RESET_STREAM or CONNECTION_CLOSE over it, and the connection stays usable.
What the net log shows
One failing request, trimmed. The whole response (headers, body, trailers and FIN) arrives in one STREAM frame, is decoded, and the fourth body read fails:
HTTP3_HEADERS_DECODED {"headers": [":status: 200", "content-type: text/plain", "trailer: x-trailer"], "stream_id": 8}
HTTP3_DATA_FRAME_RECEIVED {"payload_length": 26, "stream_id": 8}
HTTP3_HEADERS_DECODED {"headers": ["x-trailer: after-the-body"], "stream_id": 8}
HTTP_TRANSACTION_READ_BODY {}
HTTP_TRANSACTION_READ_BODY {}
HTTP_TRANSACTION_READ_BODY {}
HTTP_TRANSACTION_READ_BODY {"net_error": -100}
FAILED {"net_error": -100}
Why the wire could not show it
Our suite judges a client by what the server observes, and fails a client only on positive evidence. Chromium's failure leaves none: no STOP_SENDING, no RESET_STREAM, no CONNECTION_CLOSE, and the connection goes on serving other requests. An earlier version of the trailers test scored that as a pass, because nothing showed on the wire; the current one records it as inconclusive, because a client that decodes trailers completes its request and this one reported failure.
In the matrix today, Chromium on h-trailers is inconclusive: Nothing on the wire objected to the response, and the client process then reported the request as failed (exit status 1). A client that decodes this completes its request, so the silence is no evidence it did: whether it refused the response without signalling or failed for a reason of its own is not shown.
So we read the client's own account. Chromium's net log showed the response decoded and the body read failing with -100, and a short aioquic server reproduced it with nothing of ours involved: /plain without trailers loads in 43 ms, while /, /nt (no Trailer field) and /cl (with content-length) never finish.
The cause
Chromium's engineers traced it within a week; this is their analysis, from the issue:
- Once trailers have been decoded, QUICHE's
IsDoneReading()stays false until the trailers are marked consumed, even after the body and the FIN have been read. QuicHttpStreamposts a task to read the trailers and completes the headers at once. The firstReadResponseBody()reads the 26-byte body; the second finds no bytes and the stream not done, so it parks a callback and returnsERR_IO_PENDING.- The posted task consumes the trailers, which makes the stream done, and
OnReadTrailingHeadersComplete()then closes the read side withOnFinRead(). With the request's FIN already sent, that closes the stream, and the close resolves the parked body read withERR_CONNECTION_CLOSEDinstead of end of body.
The OnFinRead() call is a holdover from before an earlier refactoring (crbug.com/716563), when a stream's close still resolved a pending callback with OK. The unit test for trailers never caught it because it lets the event loop run before the first body read, so the trailers are consumed while body bytes are still unread. In the words of the analysis: a latent issue that has been around since at least 2021
.
Who it affects
Any HTTP/3 response that ends with a trailer section (gRPC over HTTP/3, a Server-Timing or digest sent after the body), received by anything built on Chromium's network stack:
- Google Chrome: Windows, macOS, Linux, ChromeOS and Android. Chrome on iOS loads pages through Apple's WebKit and its network stack.
- Chromium-based browsers: Microsoft Edge, Opera, Brave, Vivaldi, Samsung Internet, Yandex Browser and the rest built on Chromium share its network stack, QUIC and HTTP/3 included.
- Android System WebView: The browser engine Android apps embed to show web content: an app's in-app pages, sign-in screens and help views.
- Cronet: Chromium's network stack as a library, used by Android apps (directly, or through Google Play services) and on iOS, for their own HTTP traffic.
- Electron and CEF applications: Desktop apps that embed Chromium, such as Visual Studio Code, Slack, Discord and Microsoft Teams, when their requests go over HTTP/3.
- Headless Chromium: Puppeteer, Playwright and crawlers built on headless Chromium. This is how we met it.
Browsers on other network stacks are not built on this code: Firefox uses neqo, and Safari Apple's own stack. Every other client in our matrix that completes the trailers test is listed below.
Where the fix is
The fix, “Fix ERR_CONNECTION_CLOSED on HTTP/3 responses with trailers”, defers closing the read side while a body read is pending and makes every consumption of trailers notify a pending read of end of body. It landed on 12 Nov 2026 and is first in Chromium 160.0.8158.0.
Reported 4 Nov 2026 as Issue 581692068, with the net log and the aioquic reproduction. Found with Chrome 156.0.8078.4 (Chromium, headless, through Playwright), Linux x86-64.
Trailers across HTTP/3 clients, measured
| Client | Version | Verdict | What the server saw |
|---|---|---|---|
| ngtcp2 + nghttp3 | libcurl/8.23.0 ngtcp2/1.26.0 |
pass | Decoded it and completed the request. |
| quinn + h3 (our fork) (ours) | noq fork (2d81eb2) |
pass | Decoded it and completed the request. |
| aioquic | 1.7.2 (PyPI wheel) |
pass | Decoded it and completed the request. |
| Chromium QUICHE | build 1254 |
inconclusive | Nothing on the wire objected to the response, and the client process then reported the request as failed (exit status 1). A client that decodes this completes its request, so the silence is no evidence it did: whether it refused the response without signalling or failed for a reason of its own is not shown. |
| quic-go | quic-go v0.65.0 (h1:RAro2j2yN9a9) |
pass | Decoded it and completed the request. |
| Cloudflare quiche | 0.30.0 (d7387df) |
pass | Decoded it and completed the request. |
| neqo (Firefox) | 0.34.0 (7279e4b) |
pass | Decoded it and completed the request. |
| picoquic + picotls | 3a0c3bc |
pass | Decoded it and completed the request. |
| ngtcp2 + nghttp3 (direct) | e82176c |
pass | Decoded it and completed the request. |
| LiteSpeed lsquic + BoringSSL | 4.11.1 (cd23e52) |
fail | Rejected the response with error 0x10e. Nothing here violates the specification — this is valid HTTP/3 that a client is required to be able to process. |
| Alibaba xquic + BoringSSL | 8099bd2 + demo_client IPv6 fix |
pass | Decoded it and completed the request. |
| .NET HttpClient + msquic | 2.7.0 (d3d28f7) |
pass | Decoded it and completed the request. |
| quico + lemon-tls | 0.8.0 (npm) |
pass | Decoded it and completed the request. |
Source: the conformance matrix of 15 Nov 2026, test h-trailers.