HTTP/1.1, HTTP/2, and HTTP/3
The same requests carried three ways (one at a time per TCP connection, many at once on one TCP connection, or many at once on QUIC over UDP), which decides bundling, sprites, sharding, and how a lossy network feels.
The problem: one page, a hundred files
Open a news site's home page with the Network panel of your browser's developer tools open. You will see the HTML, then a burst of requests: a few stylesheets, a dozen script chunks, three or four fonts, an icon set, and dozens of images. HTTP Archive's 2024 Web Almanac puts the median mobile page at 66 requests. Every one of them has to travel over some connection to some server and back.
HTTP, the Hypertext Transfer Protocol, is the language of those requests and responses. The meaning has barely changed since the 1990s: a method like GET, a path, some headers, then a status like 200 and a body. What has changed, three times, is how those messages are carried: how they are cut into bytes, how many travel at once, and what happens when one of them gets stuck.
Same meaning, new transport
This is the most useful idea in the whole lesson. Your fetch() call, your Cache-Control header, and your 404 page work the same on HTTP/1.1, HTTP/2, and HTTP/3. The versions differ in the layer below: the framing on the wire and the connection underneath.
- HTTP/1.1 (1997, RFC 9112 today) sends requests as plain text lines over TCP. One connection carries one request and its response at a time.
- HTTP/2 (2015, RFC 9113 today) cuts every request and response into small binary frames and mixes many of them on a single TCP connection.
- HTTP/3 (2022, RFC 9114) keeps the HTTP/2 idea of many streams on one connection, but runs over QUIC, a new transport built on UDP, so that a lost packet only stalls the stream it belongs to.
Each version fixed one specific waste in the version before it, and the main waste has a name: head-of-line blocking, where the item at the front of a line stops everyone behind it, even those who are ready to go.
Why a frontend engineer should care
You rarely pick the HTTP version yourself. Your CDN or load balancer negotiates it with the browser. But the version decides which of your build and loading decisions are good ones.
- Should you ship one 900 KB bundle or 40 small chunks?
- Are CSS sprites and inlined images still worth the pain?
- Should images come from
img1.example.comandimg2.example.comto get more parallel downloads? - Why does a page load fine on office Wi-Fi but stall in a train, even though the files are small?
Under HTTP/1.1 the answers were: one big bundle, yes to sprites, yes to extra domains. Under HTTP/2 and HTTP/3 most of them flip, and this lesson explains why.
Check your understanding · Exercise 1
A team moves its site from HTTP/1.1 to HTTP/2 at the CDN. Which of these must the frontend code change for the site to keep working? Pick one.
HTTP/1.1: one request at a time per connection
Start with the oldest version, because its limits explain every trick that came later. An HTTP/1.1 request is plain text. You could type it by hand into a terminal connected to port 80:
GET /app.js HTTP/1.1
Host: shop.example
User-Agent: Mozilla/5.0 (Macintosh; ...)
Accept: */*
Accept-Encoding: gzip, br
Cookie: session=8f2c...; cart=3; ab=v2
HTTP/1.1 made persistent connections (keep-alive) the default: after a response finishes, the same TCP connection can carry the next request, so you don't pay a new TCP and TLS handshake every time. That was a big win over HTTP/1.0. But one rule stayed: on one connection, the next response cannot start until the previous one has fully arrived.
Why only one at a time
The response is a stream of bytes with no labels on them. The browser knows where a response ends only from its Content-Length header or from the end marker of chunked encoding. If two responses were mixed on the same connection, the browser could not tell which byte belongs to which file. So responses must come back whole, one after another, in the order they were asked for.
The spec did allow pipelining: sending requests 2, 3, and 4 without waiting for response 1. The responses still had to come back in order, so a slow response 1 (a database query, a big image) held up the fast ones behind it. Buggy proxies made it worse. Major browsers shipped it switched off or later removed it, and it is effectively dead.
This is head-of-line blocking at the HTTP level: a request at the front of a connection blocks every request behind it, even if their answers are ready on the server. Remember the phrase "at the HTTP level", because HTTP/2 fixes this one and leaves a second kind in place.
The six-connection limit
Browsers work around one-at-a-time by opening several TCP connections to the same host and running one request on each. Chrome, Firefox, and Safari all cap this at six connections per host for HTTP/1.1. The cap protects servers: without it, every page would open dozens of connections, and a busy server would run out of sockets.
Six lanes sound like a lot until you have 60 files. With six connections, the browser sends six requests, waits, and as each lane frees up it sends the next. The other 54 requests sit in a queue inside the browser. In Chrome DevTools this time shows as Stalled or Queueing in a request's Timing tab: the request is ready, but there is no free lane.
Every lane has a price
Each extra connection pays its own TCP and TLS handshakes, and starts with its own small congestion window: TCP's limit on how much it may send before hearing back, about 10 packets (roughly 14 KB) at first, growing only as acknowledgements arrive. Six connections ramp up six times and then compete for the same link.
- Round trips. On a 100 ms mobile link, a new HTTPS connection costs about 200 ms before the first request byte can be sent.
- Slow start, six times. A 200 KB script on a fresh connection needs several round trips just for TCP to grow its window, no matter how fast the link is.
So an HTTP/1.1 page with many small files is limited by round trips, not bandwidth. Halving the number of requests helps far more than doubling bandwidth, and that fact drove ten years of build tooling.
Check your understanding · Exercise 2
Over HTTP/1.1, a page needs 30 small images from one host. Each takes 1 round trip of 80 ms to fetch on an already open connection, and transfer time is tiny. About how long until all 30 have arrived, ignoring the handshakes? Pick one.
The HTTP/1.1 playbook: fewer requests, more lanes
If every request costs a round trip and only six can run at once, there are two ways to go faster: send fewer requests, or open more lanes. Frontend teams did both, and the tricks became so normal that many people still apply them without knowing why they exist.
Concatenation and bundling
Join many files into one. Twenty JavaScript files become one app.js, ten stylesheets become one app.css. Tools like Grunt and early webpack existed largely to do this. One 300 KB request pays one round trip; twenty 15 KB requests pay at least four rounds on six lanes.
The cost shows up on the next deploy. Change one line in one module and the whole bundle gets a new hash, so every returning visitor downloads all 300 KB again. With small files, only the changed file would be new. Bundling trades caching precision for fewer round trips.
Sprites and inlining
A CSS sprite packs many small images, say 40 icons, into one big image. Each icon is shown with background-position pointing at its corner of the sheet. Forty requests become one. The price: a single changed icon invalidates the whole sheet, every page downloads every icon even if it shows three, and the CSS for positions is painful to maintain.
Inlining goes further and puts a small file inside another one: a tiny image as a base64 data: URL in the CSS, or critical CSS inside a style tag in the HTML. Zero extra requests. But base64 makes the bytes about 33 percent bigger, the inlined file can no longer be cached on its own, and it is downloaded again inside every page that includes it.
Domain sharding
The six-connection cap is per host. So sites split their files across several hostnames: static1.shop.example, static2.shop.example, static3.shop.example. Three hosts gives 18 lanes. This is domain sharding, and around 2010 it was common on large sites.
Each shard costs a DNS lookup, a TCP handshake, a TLS handshake, and its own slow start, so the usual advice was two shards at most. A related trick was the cookieless domain: static files on a separate domain so the site's cookies were not sent with every image.
| Trick | What it saves on HTTP/1.1 | What it costs |
|---|---|---|
| Bundling | Round trips: many files become one request | One change re-downloads the whole bundle; code for other pages ships too |
| CSS sprites | Requests for small images | Whole sheet re-downloaded on any change; unused icons always shipped |
| Inlining (data URLs, inline CSS) | The request entirely | About 33 percent larger as base64; cannot be cached separately |
| Domain sharding | Queueing behind the six-lane cap | Extra DNS, TCP, and TLS per shard; separate slow starts |
| Cookieless domains | Cookie bytes on every static request | An extra hostname to resolve and connect to |
Notice the pattern: each trick is a fix for a transport limit, not a good idea in its own right. When the transport changes, the trade has to be redone. That is exactly what the next two versions did.
Check your understanding · Exercise 3
A team inlines a 6 KB PNG logo into its CSS as a base64 data URL. The CSS is shared by every page and changes weekly. What is the main cost? Pick one.
HTTP/2: many streams on one connection
HTTP/2 grew out of Google's SPDY experiment and became a standard in 2015. Its core idea is simple: if responses carry labels, they can be mixed on one connection, and the browser can sort them out on arrival.
Frames and streams
HTTP/2 is a binary protocol. Each request and response is cut into frames: small chunks, each with a 9-byte header that says how long it is, what type it is, and which stream it belongs to. A stream is one request and its response, numbered by the browser: 1, 3, 5, 7, and so on (browser-started streams use odd numbers).
- A
HEADERSframe carries the request or response headers, compressed. DATAframes carry the body, at most 16 KB each by default.SETTINGSframes agree on limits, such as how many streams may be open at once (servers commonly allow 100 or more).WINDOW_UPDATEframes do flow control per stream, so one huge download cannot drown the rest.RST_STREAMcancels one stream without closing the connection. When a user scrolls past an image that is still loading, the browser can drop just that stream.
Because every frame carries its stream number, the server can send a frame of app.js, then a frame of logo.svg, then more of app.js, all on the same connection. The browser reads each frame's label and appends the bytes to the right response. This is multiplexing: many conversations sharing one wire, each one complete at the end.
What multiplexing removes
- HTTP-level head-of-line blocking. A slow response no longer blocks the ones behind it. The server sends whatever is ready.
- The browser queue. All 60 requests go out at once, in a few packets. There is no six-lane limit and no Stalled time waiting for a free lane.
- Extra handshakes. One TCP and one TLS handshake per origin, instead of up to six.
- Separate slow starts. One connection grows one congestion window, and it grows faster because all responses feed it.
Try it below. With 36 small files on a 100 ms round trip, HTTP/1.1 needs six rounds of requests. HTTP/2 sends all 36 in the first round, so after the handshakes the total is roughly one round trip plus the time to push the bytes through the link.
HTTP/1.1, 36 files, 100 ms round trip: the last file arrives at about 1,376 ms, using 6 connections and 6 rounds of requests.
Only over TLS, chosen with ALPN
The spec defines HTTP/2 both with and without encryption, but every browser only speaks it over TLS. The browser and server pick the version during the TLS handshake using ALPN, Application-Layer Protocol Negotiation: the browser lists h2 and http/1.1, and the server picks one. No extra round trip is spent. If a site is plain http://, it gets HTTP/1.1 in the browser, full stop.
Plaintext HTTP/2, called h2c, exists for server-to-server traffic, such as a load balancer talking to a backend, but never appears in a browser.
Connection coalescing
HTTP/2 can go one step further and share one connection across several hostnames. If shop.example and static.shop.example resolve to the same IP address and the TLS certificate covers both names, the browser may send requests for both over the existing connection. This is connection coalescing. It is why domain shards on the same CDN sometimes collapse back into one connection on HTTP/2.
If the server receives a request for a name it does not serve on that connection, it answers 421 Misdirected Request, and the browser retries on a new connection. Seeing 421s in logs after a certificate or DNS change is a hint that coalescing is involved.
Check your understanding · Exercise 4
A developer runs a local server that supports HTTP/2 over plain http://localhost:8080, but DevTools shows every request as http/1.1. Why? Pick one.
HTTP/2 in detail: HPACK, priorities, and the end of push
Multiplexing is the headline, but three other parts of HTTP/2 matter in practice: how headers shrink, how the browser says what it needs first, and a feature that failed.
Header compression with HPACK
Headers repeat. Every request to the same site sends the same user-agent, accept-encoding, and cookie. On HTTP/1.1 that is often 500 bytes to 2 KB of text per request, so 60 requests can push tens of kilobytes of headers up a mobile uplink.
HPACK (RFC 7541) compresses headers with three tools:
- A static table of 61 common header lines that both sides know by heart. Entry 2 is
:method: GET, entry 7 is:scheme: https. Sending:method: GETcosts one byte: "entry 2". - A dynamic table that both sides build up during the connection. The first time the browser sends a 1.5 KB
cookie, it sends it in full and both sides add it to the table. On the next request the same cookie costs one or two bytes. The table defaults to 4 KB. - Huffman coding for text that is new, which often shrinks header strings by roughly a quarter, depending on the characters.
HTTP/2 also turns the request line into pseudo-headers: :method, :scheme, :authority (the host), and :path, with :status on responses. Header names must be lowercase on the wire. Your code does not see any of this; fetch() still gives you a normal Headers object.
Why not just gzip the headers? SPDY did, and the CRIME attack (2012) used how well a secret cookie compressed next to attacker-chosen text to guess it byte by byte. HPACK only reuses exact, whole header lines, which leaks far less.
Priorities: what to send first
With 60 requests in flight on one connection, the server must decide which bytes go first. Sending a hero image before the render-blocking CSS makes the page slower, so the browser tells the server what matters most.
The original HTTP/2 scheme was a tree of stream dependencies and weights. Browsers built very different trees and many servers ignored them, so RFC 9113 deprecated it. Its replacement, Extensible Priorities (RFC 9218), is simpler and works for HTTP/3 too: an urgency from 0 (most urgent) to 7, default 3, plus an incremental flag for files that are useful as they arrive, like progressive images. It can travel as a header:
priority: u=0 (render-blocking CSS)
priority: u=1 (a preloaded font or LCP image)
priority: u=3, i (a normal image, useful as it streams in)
priority: u=5 (a prefetch for the next page)
i flag lets the server interleave files of equal urgency.You influence these values from HTML. The browser assigns a priority by resource type and position, and fetchpriority="high" on the LCP image or fetchpriority="low" on a below-the-fold carousel shifts it. Priorities only help if the server honours them; most large CDNs do, but a self-hosted proxy may not. See the [critical rendering path](/system-design/concepts/critical-rendering-path) lesson for which resources deserve high priority.
Server push: a feature that failed
HTTP/2 let a server send responses the browser had not asked for yet, with a PUSH_PROMISE frame: ask for index.html, get app.css pushed too. It rarely helped. The server could not see the browser's cache, so it often pushed files the browser already had, wasting bandwidth at the most important moment of the load.
Chrome removed HTTP/2 server push in version 106 (2022) and Firefox in version 132 (2024). HTTP/3 kept push in its spec, but few clients or servers ever implemented it. Treat it as dead. Two tools replaced it:
rel=preloadin aLinkheader or alinktag: the browser decides, and it checks its cache first.103 Early Hints: an interim response the server sends while it is still building the HTML, listingLink: </app.css>; rel=preloadheaders. The browser starts fetching during the server's think time. Chrome (103 and later) and Firefox (123 and later) act on preload hints in it; Safari (17 and later) acts only on preconnect hints.
Check your understanding · Exercise 5
A site sends a 2 KB cookie on every request. It moves from HTTP/1.1 to HTTP/2. What happens to the cookie cost for 50 requests on one connection? Pick one.
The blocking HTTP/2 could not fix
HTTP/2 removed head-of-line blocking at the HTTP level. It did not remove it at the TCP level, and in one common situation it made things worse. To see why, you need one fact about TCP.
TCP delivers bytes in order, always
TCP promises the receiver one ordered stream of bytes. Data travels in packets of about 1.4 KB each, numbered by their byte position. If packet 4 is lost and packets 5, 6, and 7 arrive, the operating system keeps 5, 6, and 7 in a buffer and hands nothing to the browser until packet 4 is resent and arrives. That takes at least one more round trip, often more.
TCP has no idea that packet 4 held a piece of app.css while packets 5 to 7 held pieces of hero.jpg and app.js. To TCP it is all one stream. So a single lost packet freezes every HTTP/2 stream on the connection, including the ones whose data has already arrived and is sitting in the buffer.
All the eggs in one basket
On HTTP/1.1, a lost packet freezes one of six connections; the other five keep going. On HTTP/2, everything is on one connection, so one loss freezes everything. TCP also reacts to loss by cutting its congestion window, by 30 to 50 percent depending on the algorithm, and one connection takes that cut for the whole page instead of one sixth of it.
On a wired or good Wi-Fi network, loss is well below 1 percent and HTTP/2 wins comfortably. On a crowded mobile network or a weak signal, 1 to 2 percent loss is common, and measurements at that level have found HTTP/2 doing no better, sometimes worse, than six HTTP/1.1 connections. The users who suffer are the ones on the worst networks.
| Question | HTTP/1.1 (six connections) | HTTP/2 (one connection) |
|---|---|---|
| HTTP-level head-of-line blocking | Yes: one response at a time per connection | No: streams interleave freely |
| TCP-level head-of-line blocking | Yes, but a loss stalls only one of six connections | Yes, and a loss stalls every stream |
| Congestion window cut on loss | Only the one affected connection slows | The whole page slows |
| Best on | Lossy links with few requests | Low-loss links with many requests |
Why not just fix TCP?
TCP lives in the kernel of every device and is inspected by millions of firewalls and load balancers that drop packets that look unfamiliar. Changing it means waiting for all of them to update, which takes a decade or more. This is called ossification.
The way out: a new transport on top of UDP, which middleboxes already pass, built into browsers and servers where it can ship every few weeks, and encrypted so no middlebox can freeze it again. That transport is QUIC.
Check your understanding · Exercise 6
On an HTTP/2 connection, a packet carrying part of app.css is lost. Packets with the rest of hero.jpg arrive fine. What does the browser see for hero.jpg until the lost packet is resent? Pick one.
HTTP/3: streams move into the transport
QUIC (RFC 9000, 2021) is a transport protocol, a replacement for TCP plus TLS, that runs on top of UDP. HTTP/3 (RFC 9114, 2022) is HTTP mapped onto QUIC. The HTTP part is a thin layer now: almost everything interesting happens in QUIC.
Streams the transport understands
The key change: streams are now part of the transport itself. QUIC knows that bytes 0 to 1,400 of stream 4 are app.css and bytes 0 to 1,400 of stream 8 are hero.jpg. It keeps order within each stream, but streams are independent of each other.
So when a packet carrying part of app.css is lost, only the CSS stream waits for the resend. Image and script bytes that arrived are handed to the browser at once. The HTTP/2 promise, that one slow file does not block the others, is finally true all the way down. Step through it below and switch between HTTP/2 and HTTP/3 to compare.
The server sends six packets on one TCP connection: two each of app.css, hero.jpg, and app.js.
TLS built in, and fewer round trips
QUIC does not run TLS on top of itself as a separate step; TLS 1.3 is part of its handshake, and every QUIC connection is encrypted. Even most of the packet header, including packet numbers, is encrypted, so middleboxes cannot read or depend on it. That is deliberate: it keeps QUIC free to change.
- TCP plus TLS 1.3: one round trip for the TCP handshake, one for TLS, then the request. Two round trips before the first request byte leaves.
- QUIC, first visit: transport and crypto handshake together in one round trip, then the request.
- QUIC, return visit with 0-RTT: the browser sends the request in its very first packet, encrypted with keys saved from the last visit. Zero round trips before the request.
On a 150 ms mobile round trip, going from two round trips to one saves 150 ms on every new connection, and 0-RTT saves another 150 ms on repeat visits. TLS 1.3 over TCP has a 0-RTT mode too, but in browsers it is most common with HTTP/3.
The catch with 0-RTT: replay
0-RTT data is sent before the server has had a chance to prove the connection is fresh. An attacker who records that first packet can send it again, and the server may process the request twice. That is harmless for GET /app.js and dangerous for POST /checkout.
- Browsers only send safe, idempotent requests (like
GETwithout side effects) as 0-RTT early data. - Servers and CDNs can refuse early data, or accept it only for certain paths.
- A CDN that forwards early data to your backend adds the header
Early-Data: 1. The backend can reply425 Too Early(RFC 8470) to make the browser retry after the handshake.
The design lesson: never make a GET change state. That was always good HTTP practice, and 0-RTT turns it into a security requirement.
Connection IDs and network changes
A TCP connection is identified by four numbers: the two IP addresses and the two ports. When a phone walks out of Wi-Fi range and switches to mobile data, its IP address changes and every TCP connection dies. The page has to reconnect from scratch.
QUIC identifies a connection by a connection ID, not by addresses, so after a quick check of the new path the same connection can continue. The protocol supports this connection migration; how much each browser and server uses it varies, and it matters most for long downloads and streams on mobile.
QPACK: HPACK without the ordering problem
HPACK assumes header blocks arrive in the order they were sent, which TCP guarantees. QUIC streams do not arrive in order relative to each other. If stream 8's headers refer to a dynamic-table entry that stream 4 added, and stream 4's packet is lost, stream 8 cannot be decoded: head-of-line blocking again, through the back door.
QPACK (RFC 9204) solves this. Table updates travel on a dedicated one-way encoder stream, and the decoder acknowledges them on its own stream. The encoder chooses, per header block, whether to reference only entries the decoder has confirmed (never blocks) or to risk referencing newer ones (better compression, may wait briefly). QPACK also has a bigger static table of 99 entries, tuned for common web headers.
Check your understanding · Exercise 7
A CDN enables 0-RTT for an e-commerce site. Which endpoint is most at risk from replayed early data? Pick one.
Getting to HTTP/3: discovery, fallback, and failure modes
HTTP/2 is chosen inside the TLS handshake with ALPN. HTTP/3 cannot be, because it runs over UDP: by the time ALPN speaks, the browser has already picked TCP. So the browser must learn before connecting that a server speaks HTTP/3. There are two ways.
Alt-Svc: learn on the first visit, use on the next
The first time, the browser connects over TCP with HTTP/2 or HTTP/1.1. The server includes an Alt-Svc (alternative service) header in its response:
HTTP/2 200
content-type: text/html
alt-svc: h3=":443"; ma=86400
The browser caches that hint. For later connections to the origin, it tries QUIC. The first page load of a new visitor therefore usually starts on HTTP/2. Chrome can open a QUIC connection in the background once it sees the hint, so later requests on that first page may already show h3.
HTTPS DNS records: know before the first connection
The newer way puts the hint in DNS. An HTTPS record (a kind of SVCB record, RFC 9460) is returned alongside the address lookup and can say alpn="h3,h2". The browser learns that HTTP/3 is available before it opens any connection, so even a first visit can start on QUIC.
Records can also carry IP hints and the keys for Encrypted Client Hello. Recent versions of Chrome, Firefox, and Safari use HTTPS records, with differences by platform and DNS setup, and most large CDNs publish them for you.
alt-svc header remembered from an earlier response. Either way, TCP stays as the fallback.When UDP is blocked
Some corporate firewalls, hotel networks, and older middleboxes block or throttle UDP on port 443. If QUIC silently fails, a naive client would hang. Browsers protect against this: they race a QUIC attempt against a TCP attempt, or fall back to TCP quickly, and remember for a while that QUIC failed on this network. The user gets HTTP/2 a little later instead of a broken page.
This is why HTTP/3 is always deployed alongside HTTP/2, never instead of it. Your origin or CDN must keep serving h2 over TCP on port 443, and your firewall must allow UDP 443 inbound if you terminate QUIC yourself.
What goes wrong in production
- Old tricks on a new protocol. Leftover image shards on HTTP/2 show up as several
h2connections in the waterfall where one would do, each with its own handshake, slow start, and priority order. Fix: serve from one origin, or let coalescing merge the shards. - HTTP/1.1 hiding behind a CDN. The CDN speaks
h3to users, but a third-party script host or an API on a different domain still speaks HTTP/1.1, and its requests queue behind the six-lane cap. Check the Protocol column for every origin, not just your main one. - Ignored priorities. A proxy that does not implement priorities sends bytes first-come, first-served, so a big image can delay render-blocking CSS that started earlier. Fix: use a server or CDN that honours
priority, and setfetchpriorityon the elements that matter. - QUIC CPU cost. QUIC runs in user space and encrypts every packet, so it can cost more server CPU per byte than kernel TCP. A CDN absorbs this; if you run QUIC yourself, measure.
Check your understanding · Exercise 8
A CDN enables HTTP/3 and sends alt-svc: h3=":443"; ma=86400, but publishes no HTTPS DNS record. A brand new visitor opens the site. Which protocol most likely carries the very first HTML request? Pick one.
What changes for bundling, sprites, and sharding
Now redo the HTTP/1.1 trades from chapter 3 with the new transport. The short version: requests became cheap, but not free, and the reasons to bundle shifted from round trips to compression, caching, and discovery.
Bundling: from one file to a few dozen
On HTTP/2 and HTTP/3, 40 requests cost about the same round trips as 4. That frees you to split code by route and by how often it changes, so a deploy that touches the checkout page does not invalidate the framework code every page shares. But going all the way to hundreds of tiny unbundled modules is still slower, for four reasons:
- Compression works better on bigger files. Brotli and gzip find repeats within a file. Fifty 2 KB modules compress worse in total than one 100 KB file of the same code.
- Each request still has a cost: a cache lookup, a priority decision, frame headers, and work on both ends. Small, but it adds up past a few hundred.
- Import waterfalls. Unbundled ES modules are discovered one level at a time: the browser must download
main.jsto see that it importsrouter.js, then download that to see its imports. Each level is a round trip that multiplexing cannot remove, because the browser does not know the next URL yet. Bundlers flatten this tree;modulepreloadcan list deeper files up front. - Priorities get noisy. Hundreds of equal-priority streams compete for the same bandwidth and finish late together, instead of the critical ones finishing early.
Modern bundlers aim for the middle: a framework chunk that rarely changes, a few shared library chunks, one chunk per route, and lazy chunks for heavy widgets. The [code splitting](/system-design/concepts/code-splitting) lesson covers how to choose the split points.
Sprites and inlining
Raster CSS sprites are no longer worth their cost. Ship each image on its own, cache it on its own, and let the page download only what it shows. Icons are usually best as inline SVG inside components, or as one SVG sprite with use references if a design system needs hundreds of them; the reason there is markup size and caching, not request count.
Inlining is now a narrow tool. Inlining the small critical CSS for the first paint still saves a full round trip on the first visit, because the browser cannot request the stylesheet until it has read the HTML. Inlining images as base64 almost never pays off on HTTP/2.
Domain sharding: undo it
On HTTP/2 and HTTP/3, sharding is harmful. Every extra hostname adds a DNS lookup, a handshake, and a fresh congestion window, and splits your files across connections that cannot share one priority order. Serve page assets from one origin, ideally the page's own origin through the CDN, so the HTML connection is reused. If shards must stay, put them on the same IP and certificate so coalescing can merge them.
Decision How should a large single-page app ship its JavaScript over HTTP/2 or HTTP/3?
What we pick: Pick granular chunks for almost every production app on HTTP/2 or HTTP/3: they keep the caching win of small files without the waterfall and compression costs of no bundling. A single bundle still fits a small site with one page. Unbundled modules suit development servers, not production.
In real products
- Google built QUIC and serves Search, YouTube, and its other properties over it.
- Meta reported in 2020 that over 75 percent of its internet traffic used QUIC and HTTP/3, with fewer request errors and lower tail latency in its apps.
- Major CDNs (Cloudflare, Fastly, Akamai, Amazon CloudFront) offer HTTP/3 as a setting, so most sites get it with no code change.
Check your understanding · Exercise 9
A site on HTTP/2 serves images from img1.cdn.example and img2.cdn.example on different IP addresses, as a leftover from HTTP/1.1 days. What is the best change? Pick one.
Using it in a system design interview
HTTP versions rarely get their own question. They come up inside performance answers: "how would you make this page load fast?", "why is the feed slow on mobile?", "how should we ship the JavaScript?". Bring them up when they change a decision, and keep it short unless the interviewer digs in.
When to bring it up
- When you choose a bundling or code-splitting strategy. Many parallel requests are cheap on HTTP/2 and HTTP/3, so split by route and change rate, but avoid hundreds of tiny files.
- When you put assets on a CDN. One origin for assets, no domain sharding, HTTP/3 enabled with HTTP/2 fallback.
- When users are on mobile networks. TCP head-of-line blocking under loss, and why HTTP/3 helps the slow tail most.
- When you add real-time features. On HTTP/1.1, each SSE or long-polling stream holds one of six connections per host; on HTTP/2 they share one connection.
A two-sentence summary to have ready
"HTTP/1.1 runs one request at a time per connection, so browsers open six connections per host and we used to bundle, sprite, and shard to cut requests. HTTP/2 multiplexes streams on one TCP connection with HPACK header compression, which makes those tricks unnecessary, but a lost packet still stalls every stream; HTTP/3 moves streams into QUIC over UDP so a loss stalls only its own stream, and adds faster handshakes and 0-RTT for safe requests."
How deep to go by level
| Level | What a strong answer includes |
|---|---|
| Junior | Knows HTTP/2 multiplexes requests on one connection and that bundling everything into one file is no longer required. Can find the Protocol column in DevTools. |
| Mid | Explains the six-connection limit, why sprites and sharding existed, HPACK, and that HTTP/2 only fixed HTTP-level blocking. Picks granular code splitting and justifies it. |
| Senior | Explains TCP-level head-of-line blocking under loss, QUIC streams, 0-RTT replay limits, QPACK, Alt-Svc and HTTPS records, UDP fallback, priorities with fetchpriority, and why push was removed in favour of Early Hints. Proposes measuring nextHopProtocol by network type. |
If you draw anything, draw two waterfalls: six stair-stepped lanes for HTTP/1.1 next to one connection of interleaved streams for HTTP/2.
Check your understanding · Exercise 10
An interviewer says: "We moved to HTTP/2, so we can delete our bundler and ship our 600 source files directly." What is the best response? Pick one.
Summary
- All three versions share the same HTTP meaning (methods, headers, status codes, caching); they differ in how messages are carried. Application code does not change.
- HTTP/1.1 sends one response at a time per connection, and browsers allow six connections per host. Bundling, sprites, inlining, and domain sharding were workarounds for that limit.
- HTTP/2 splits messages into frames tagged with stream IDs and multiplexes them on one TLS connection, with HPACK compressing repeated headers. Browsers only use it over TLS, chosen with ALPN.
- HTTP/2 removed HTTP-level head-of-line blocking but not TCP-level: one lost packet stalls every stream on the connection, which hurts most on lossy mobile networks.
- HTTP/3 runs on QUIC over UDP: independent streams in the transport, TLS 1.3 built in, a one-round-trip handshake, 0-RTT for safe requests, connection IDs, and QPACK instead of HPACK.
- Browsers discover HTTP/3 through
Alt-Svcor HTTPS DNS records and fall back to HTTP/2 when UDP is blocked, so both stay enabled. - On HTTP/2 and HTTP/3: undo domain sharding, drop raster sprites, inline only critical CSS, and ship granular chunks (a few dozen files) rather than one bundle or hundreds of modules.
- Server push is dead. Use preload and
103 Early Hints, and steer priorities withfetchpriority.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Why did HTTP/1.1 sites bundle files, use sprites, and shard domains, and should you still do that?
HTTP/1.1 carries one response at a time per connection, and browsers open at most six connections per host. With 60 files, most requests queue, and each round of six costs a round trip. Bundling and sprites cut the number of requests; sharding across hostnames got more lanes.
On HTTP/2 and HTTP/3, many requests share one connection at once, so request count matters far less. Sharding now hurts, because each host needs its own handshake and congestion window and splits the priority order. Sprites hurt caching for little gain.
Bundling survives in a moderate form: granular chunks by route and change rate, a few dozen files at most, because compression is better on larger files and unbundled imports cause round-trip waterfalls.
Question 2
HTTP/2 solved head-of-line blocking. Why do we need HTTP/3?
HTTP/2 solved head-of-line blocking at the HTTP level: frames carry stream IDs, so responses interleave and a slow one does not block the others.
It still runs on TCP, which delivers one ordered byte stream. A single lost packet makes the kernel hold every later byte, for every stream, until the resend arrives. On lossy mobile links that can make HTTP/2 no better than six HTTP/1.1 connections.
HTTP/3 runs on QUIC over UDP, where streams are part of the transport. A loss stalls only the stream it belongs to. QUIC also merges the transport and TLS 1.3 handshakes into one round trip, offers 0-RTT on repeat visits, and can survive a network change using connection IDs.
Question 3
What is 0-RTT and what is the risk?
On a repeat visit, a QUIC client can send its first request in its very first packet, encrypted with keys from the previous session, so the request costs zero handshake round trips.
The risk is replay: an attacker can resend that packet, and the server cannot tell it from the original. So only safe, idempotent requests should be sent or accepted as early data.
Browsers limit early data to safe requests, servers can reject it, and a backend can answer 425 Too Early when it sees Early-Data: 1. The application rule is simple: a GET must never change state.
Question 4
How does a browser know a site supports HTTP/3?
HTTP/2 is negotiated inside the TLS handshake with ALPN, but HTTP/3 runs over UDP, so the browser must learn about it before connecting.
The classic way is the Alt-Svc response header, such as h3=":443"; ma=86400, which the browser remembers and uses for later connections. The newer way is an HTTPS DNS record with alpn="h3,h2", which allows HTTP/3 from the very first connection.
Either way the browser races QUIC against TCP or falls back quickly, because some networks block UDP. That is why HTTP/2 stays enabled alongside HTTP/3.
Question 5
What happened to HTTP/2 server push, and what replaced it?
Server push let the server send files the browser had not requested yet. It rarely helped, because the server could not see the browser cache and often pushed files the browser already had, wasting bandwidth during the critical first moments.
Chrome removed it in version 106 and Firefox in version 132, and few HTTP/3 clients or servers ever implemented it.
The replacements are rel=preload hints, which let the browser check its cache first, and 103 Early Hints, which sends those preload hints while the server is still generating the HTML.
References
- RFC 9113: HTTP/2 The current HTTP/2 spec, including frames, streams, and the deprecated priority tree.
- RFC 9114: HTTP/3 HTTP mapped onto QUIC streams.
- RFC 9000: QUIC The transport: streams, connection IDs, loss recovery, migration.
- RFC 9204: QPACK Header compression for HTTP/3 and how it avoids head-of-line blocking.
- RFC 9218: Extensible Prioritization Scheme for HTTP The
priorityheader and urgency levels used by HTTP/2 and HTTP/3. - MDN: Evolution of HTTP A readable history from HTTP/0.9 to HTTP/3.
- Chrome for Developers: Removing HTTP/2 Server Push from Chrome Why push was removed and what to use instead.
- Chrome for Developers: Early Hints How
103 Early Hintsstarts critical fetches during server think time. - MDN: 103 Early Hints The status code and which browsers act on preload and preconnect hints.
- Web Almanac 2024: Page Weight HTTP Archive data on requests per page, including the median of 66 on mobile.
- Engineering at Meta: How Facebook is bringing QUIC to billions Meta's 2020 report on moving over 75 percent of its traffic to QUIC and HTTP/3.
Read next
- From URL to pixelsEverything that happens between pressing Enter and seeing a page: DNS, TCP, TLS, the HTTP exchange, redirects, parsing, and subresources, with where the time goes on a real phone, so you know which part to fix when a page is slow.
- The critical rendering pathEverything the browser must fetch and compute before the first pixels: HTML, render-blocking CSS, parser-blocking scripts, then style, layout, paint, and composite, and how to count, measure, and shorten it.
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- DNS, CDNs, and the edgeHow a name becomes an address, how that address leads to a server a few milliseconds from the user instead of an ocean away, and what code and files a frontend should run and keep there.
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.