HTTP/2 and Flutter Web: What the Browser Already Does for You
How HTTP/2 changes loading a Flutter web app: framing, one connection per origin, ALPN negotiation, preload for main.dart.js, dead server push, and the HTTP/3 ceiling.
Published on • November 11, 2026
AI Assistant

HTTP/2 never changed what your Flutter code asks for — methods, URLs, and headers are identical — it changed how the browser ships those requests. For a Flutter web app most of the win is automatic, so the useful work is knowing what to verify, what to preload, and what not to rely on.
One connection, many streams
HTTP/2 replaces the newline-delimited text protocol with a binary framing layer: every frame carries a fixed 9-byte header with a length, a type, flags, and a 31-bit stream identifier, and frames from independent streams interleave over a single TCP connection per origin. That removes HTTP/1.1’s six-connections-per-origin queue — in Firefox’s early measurements, the share of connections carrying a single transaction fell from 74% on HTTP/1 to 25% on HTTP/2.
The rest of the toolbox follows from that framing. Headers compress with HPACK (static Huffman coding plus a shared indexed table of previously seen fields), streams carry weights and dependencies so the browser can express priority, and credit-based flow control runs per stream and per connection. None of it reaches your Dart code; the client and server absorb it all, which is exactly why application semantics never changed.
The frame vocabulary is small — HEADERS, DATA, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY — and GOAWAY is the one that shows up during deploys: the server announces the connection is draining, in-flight streams finish, and the browser opens a fresh connection instead of failing your asset requests mid-load.
Negotiation happens inside TLS
For HTTPS, the protocol is agreed during the TLS handshake with ALPN: the client offers h2, the server selects it, and no extra round trip is spent. The cleartext path — an HTTP/1.1 request with Upgrade: h2c — exists in RFC 7540 for plain HTTP, but MDN notes that HTTP/2 explicitly disallows the Upgrade mechanism: it is HTTP/1.1-specific and today mostly used to bootstrap WebSockets.
So verify what your host actually negotiates rather than assuming it. In DevTools, add the Protocol column to the Network panel and confirm main.dart.js shows h2; from a shell:
curl -sI --http2 https://blog.redlinesoft.net/ | head -1 # HTTP/2 200
echo | openssl s_client -alpn h2 -connect blog.redlinesoft.net:443 2>/dev/null | grep -i alpn
If your CDN or origin answers HTTP/1.1 there, every “HTTP/2 benefit” below is silently off — fix the host before tuning the app.
What HTTP/2 buys, and what HTTP/1.1 costs
On a high-latency link the gains are concrete: one handshake instead of several, every asset dispatched the moment the HTML parser discovers it, and no head-of-line queueing behind a slow HTTP/1.1 connection. Two practical moves follow.
First, preload the boot bundle so discovery starts before parsing reaches it:
<link rel="preload" href="main.dart.js" as="script" crossorigin>
<link rel="preload" href="flutter.js" as="script" crossorigin>
<link rel="preload" href="assets/FontManifest.json" as="fetch" crossorigin>
Preload only what the boot path truly needs — each tag competes for bandwidth with everything else on the single connection, and a speculative preload of a lazily-loaded asset actively delays the bundle you are waiting for.
Second, do not design around server push. It was part of HTTP/2 — PUSH_PROMISE and all — but it proved tricky in practice and has been removed from most major browser engines; MDN points at rel="preload" and 103 Early Hints as the replacements. Your server may still implement push; your browsers will mostly ignore it, so preload instead.
A Flutter web build makes the case concrete: main.dart.js, flutter.js, the WASM renderer bundle, web fonts, and dozens of asset files all ship from the same origin. On HTTP/1.1 that fleet competes for six sockets and pays a fresh header round trip per request; on HTTP/2 they dispatch in parallel over the connection you already have.
On the flip side, when ALPN negotiation fails — misconfigured proxy, host that never enabled h2 — the browser silently falls back to HTTP/1.1, and the costs return: six concurrent connections per origin, request queuing behind that limit, uncompressed headers costing 500–800 bytes each, and no server-side prioritization signal. You can reproduce what a client sees:
curl -sI --http1.1 https://blog.redlinesoft.net/ | head -3 # HTTP/1.1 200, Connection: keep-alive
curl -sI https://blog.redlinesoft.net/ | grep -i alt-svc # may advertise h3 for HTTP/3
Treat that first command as a smoke test in your deploy pipeline: if it stops saying HTTP/2, the regression is upstream of Flutter.
One detail worth internalizing: the six-connections-per-origin ceiling is hard-coded in browsers, not a knob you can raise. A Flutter app that fires twenty asset requests at once under HTTP/1.1 queues fourteen of them behind six sockets, and every queued request pays a fresh header round trip because HPACK never gets a chance to help.
On the Dart side, the browser owns the connection
dart:io’s HttpClient does not exist on web, so native-side ALPN tuning, pooling, and keep-alive policy are off the table entirely. package:http falls back to its browser client, which runs on fetch/XHR:
import 'package:http/http.dart' as http;
final client = http.Client(); // BrowserClient under the hood on web
final response = await client.get(Uri.parse('/api/status'));
print('${response.statusCode} over whatever the browser negotiated');
Connection reuse, header compression, and stream prioritization all happen below that call. The Dart-side lever that still matters is request shape: fewer, well-sized requests beat a swarm of tiny ones, because the browser’s own scheduler is what actually runs.
TCP, HTTP/3, and the post-deploy audit
HTTP/2 removed head-of-line blocking at the HTTP layer but not at TCP: one lost packet stalls every stream riding that connection, and the shared congestion window throttles them all — the reason a single connection wins on paper yet loses on lossy links. HTTP/3 fixes precisely this by moving HTTP onto QUIC over UDP, where streams recover independently. You pick it up for free where the CDN supports it — watch for h3 in the Protocol column or an Alt-Svc header — with zero code changes, the same bargain HTTP/2 offered.
The protocol itself deserves the same discipline — run this two-minute audit after every hosting or CDN change:
- DevTools → Network → right-click the column headers → enable Protocol, then confirm
h2onmain.dart.jsand your API calls. - Put
curl -sI --http2 https://your-host/ | head -1in CI and fail the build if it printsHTTP/1.1. - Open the Timing tab and check the handshake:
Protocol: h2means ALPN succeeded and no upgrade round trip was spent. - Note any
Alt-Svc: h3response header while you are there — that is the free upgrade waiting on the next hop. - Keep asset hosts to one origin where you can: multiplexing is per connection, so a CDN that splits files across subdomains buys you parallel connections, not parallel streams.
- If request counts look healthy but load is still slow, inspect payload bytes: multiplexing fixes round trips, never payload size.
- Confirm
main.dart.jsappears early in the waterfall — if discovery starts late, no protocol will save that render.
Takeaway: HTTP/2 is a browser-side win for Flutter web — confirm h2 in DevTools, preload main.dart.js, skip server push entirely, and let package:http’s browser client ride the single multiplexed connection until HTTP/3 takes over.