cf.completefrontend
My progress
FUNDAMENTALS · MODULE 2

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.

A layered stack of the three HTTP versions. On top, shared by all three, HTTP semantics: methods, status codes, headers, and caching. HTTP/1.1 carries them as text messages, one at a time, over optional TLS, over TCP. HTTP/2 carries them as binary frames with streams and HPACK, over TLS, over TCP. HTTP/3 carries them as HTTP/3 frames with QPACK, over QUIC with TLS 1.3 built in, over UDP. All three sit on IP.HTTP semantics: GET, 200, headers, Cache-ControlHTTP/1.1text messagesone at a timeTLSoptionalTCPordered bytesHTTP/2binary framesstreams, HPACKTLS 1.2 or 1.3required in browsersTCPordered bytesHTTP/3HTTP/3 framesQPACKQUIC + TLS 1.3per-stream lossUDPplain datagramsIP: packets across the internet
The top layer is what your code sees and it never changes. Each new version replaces the layers underneath: HTTP/2 changes the framing, HTTP/3 also replaces TCP and TLS with QUIC.

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.com and img2.example.com to 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:

http
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




A request is lines of text ending in an empty line. The response comes back the same way: a status line, headers, an empty line, then the body.

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.

A timeline of 30 small images loaded over HTTP/1.1 from one host, with an 80 ms round trip. Six connections each carry one request at a time. Files 1 to 6 go in the first round, files 7 to 12 in the second, and so on, so the 30th file arrives after five rounds, at 400 ms. Files 7 to 30 spend their early time waiting in the browser's queue.30 images, one host, HTTP/1.1, 80 ms round tripconn 1img 1img 7img 13img 19img 25conn 2img 2img 8img 14img 20img 26conn 3img 3img 9img 15img 21img 27conn 4img 4img 10img 16img 22img 28conn 5img 5img 11img 17img 23img 29conn 6img 6img 12img 18img 24img 30080160240320400mssent at oncewaited in the queue for a lane
Each row is one connection and each box is one request. Only the first six start right away; the other 24 wait in the browser queue for a free lane, which DevTools shows as Stalled time.

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.

Before and after a CSS sprite. Before: 40 small icon files, one request each, which on HTTP/1.1 queue behind the six-connection limit, but each is cached on its own. After: one sprite.png holding all 40 icons, one request, but changing one icon re-downloads all 40, and every page downloads every icon.before: one file per iconpackafter: one sprite sheetsprite.png, 40 icons40 requests, 7 roundson six laneseach icon cached alone1 requestone icon changes:all 40 download again
The sprite removes 39 requests, which mattered a lot with six lanes. It pays for that in caching: the sheet is one file, so any change or any unused icon costs the whole thing.

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.

Every HTTP/1.1 trick is a trade: it removes a round trip or a lane limit and pays for it in caching, bytes, or extra connections.
TrickWhat it saves on HTTP/1.1What it costs
BundlingRound trips: many files become one requestOne change re-downloads the whole bundle; code for other pages ships too
CSS spritesRequests for small imagesWhole sheet re-downloaded on any change; unused icons always shipped
Inlining (data URLs, inline CSS)The request entirelyAbout 33 percent larger as base64; cannot be cached separately
Domain shardingQueueing behind the six-lane capExtra DNS, TCP, and TLS per shard; separate slow starts
Cookieless domainsCookie bytes on every static requestAn 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 HEADERS frame carries the request or response headers, compressed.
  • DATA frames carry the body, at most 16 KB each by default.
  • SETTINGS frames agree on limits, such as how many streams may be open at once (servers commonly allow 100 or more).
  • WINDOW_UPDATE frames do flow control per stream, so one huge download cannot drown the rest.
  • RST_STREAM cancels one stream without closing the connection. When a user scrolls past an image that is still loading, the browser can drop just that stream.
The layout of an HTTP/2 frame. A 9-byte header: Length, 3 bytes, for example 16,384; Type, 1 byte, for example DATA; Flags, 1 byte, for example END_STREAM; and Stream ID, 4 bytes, for example 5. Then the payload, such as bytes of app.js. Below, one connection carrying frames from streams 1, 3, and 5 interleaved: HEADERS 1, HEADERS 3, HEADERS 5, DATA 3, DATA 1, DATA 5, DATA 3, DATA 1.9-byte frame header (field sizes in bytes)Length (3)16,384Type (1)DATAFlags (1)END_STREAMStream ID (4)5payloadapp.jsone connection, frames from three streams taking turnsH 1H 3H 5D 3D 1D 5D 3D 1H is HEADERS, D is DATA: 1 app.js, 3 app.css, 5 logo.svg
Every frame says which stream it belongs to, so frames from different files can take turns on one connection and the browser puts each file back together.

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.

Protocol
Files
Round trip
A waterfall of the load. 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.handshakerequest outbytes inconn 1conn 2conn 3conn 4conn 5conn 6034069010301380ms
Simplified model: each file is 20 KB on a 10 Mbps link, files in flight share the link, and TCP slow start is ignored. The time axis is scaled to the slowest of the three protocols, so you can compare them by switching.

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: GET costs 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.

HPACK's two tables and an encoded request. The static table has 61 fixed entries, such as 2 for :method GET, 4 for :path slash, 7 for :scheme https, and 8 for :status 200. The dynamic table is built during the connection and starts at index 62: 62 is the user-agent, 63 is a 1.5 KB cookie. The second request is encoded as indexes: :method GET, :scheme https, the user-agent, and the cookie each cost 1 byte; the new path /cart costs about 6 bytes.static table: 61 fixed entries2 :method: GET4 :path: /7 :scheme: https8 :status: 200dynamic table: per connection62 user-agent: Mozilla...63 cookie: session=8f2c...request 2 on the wire:method: GET = 1 byte:scheme: https = 1 byteuser-agent = 1 bytecookie (1.5 KB) = 1 byte:path: /cart = about 6 bytesrequest 1 sends the cookie once; later ones send its index
The first request teaches both sides its long headers. After that, a 1.5 KB cookie is one byte: an index into the dynamic table.

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:

http
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)
Illustrative values; each browser picks its own. Urgency 0 is sent first, and the 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=preload in a Link header or a link tag: 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, listing Link: </app.css>; rel=preload headers. 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.

A sequence diagram of TCP head-of-line blocking on HTTP/2. The server sends packet 1 with app.css, which TCP on the phone delivers to the page. Packet 2, also app.css, is lost. Packets 3, with hero.jpg, and 4, with app.js, arrive but sit in TCP's buffer. TCP tells the server it still needs packet 2. The server resends it one round trip later, and only then does TCP deliver packets 2, 3, and 4 to the page together.pagephone TCPserver1: app.cssdeliver 12: app.css, lost3: hero.jpg4: app.js3 and 4 waitin the bufferstill need 22 againdeliver 2, 3, 4
Packets 3 and 4 are already on the phone, but TCP must hand over bytes in order, so the image and the script wait for a resend of a CSS packet they have nothing to do with.

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.

HTTP/2 traded many independent pipes for one smart pipe. That is a clear win until the pipe drops a packet.
QuestionHTTP/1.1 (six connections)HTTP/2 (one connection)
HTTP-level head-of-line blockingYes: one response at a time per connectionNo: streams interleave freely
TCP-level head-of-line blockingYes, but a loss stalls only one of six connectionsYes, and a loss stalls every stream
Congestion window cut on lossOnly the one affected connection slowsThe whole page slows
Best onLossy links with few requestsLow-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.

Transport
Step 1 of 6
HTTP/2 over TCP, step 1 of 6. Packets 1 to 6: 1 in flight, 2 in flight, 3 in flight, 4 in flight, 5 in flight, 6 in flight. Delivered to the browser: app.css 0 of 2, hero.jpg 0 of 2, app.js 0 of 2.packets, HTTP/2 over TCP1 css2 css3 img4 js5 img6 jswhat the browser hasapp.csspacket 1packet 20 of 2hero.jpgpacket 3packet 50 of 2app.jspacket 4packet 60 of 2
Top: the six packets and where each one is. Bottom: how much of each file the browser has. Hatched means the bytes are on the phone but held back; red means lost.

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 GET without 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 reply 425 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
HTTP/2 200
content-type: text/html
alt-svc: h3=":443"; ma=86400
"This origin is also available over HTTP/3 on UDP port 443; remember that for 86,400 seconds (one day)."

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.

A flowchart of how a browser picks HTTP/3. To open shop.example, it first checks whether the DNS HTTPS record lists h3. If yes, it races QUIC against TCP. If no, it checks whether it has a cached Alt-Svc hint for h3. If yes, it also races QUIC against TCP; if no, it connects with TCP and TLS, and ALPN picks h2 or HTTP/1.1, and the response's Alt-Svc header is remembered for next time. When racing, the browser uses h3 if QUIC answers, and falls back to h2 if UDP is blocked.open shop.exampleDNS HTTPS record: h3?yesnocached alt-svc: h3?race QUIC and TCPyesnoTCP + TLS, ALPN: h2h3 if QUIC answersalt-svc arrives for next timeno UDP: fall back to h2
Two ways in: an HTTPS DNS record before the first connection, or an 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 h2 connections 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 h3 to 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 set fetchpriority on 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.

A comparison matrix of HTTP/1.1, HTTP/2, and HTTP/3. Requests at once per connection: 1, many, many. HTTP-level head-of-line blocking: yes, no, no. TCP-level head-of-line blocking: per connection, all streams, none. Header compression: none, HPACK, QPACK. Round trips before the first request on a new HTTPS connection: 2, 2, and 1 or 0 on a repeat visit. One big bundle: helps, not needed, not needed. Sprites and domain sharding: help, hurt, hurt.HTTP/1.1HTTP/2HTTP/3requests at once, per conn1manymanyHTTP-level HOL blockingyesnonoTCP-level HOL blockingper connall streamsnoneheader compressionnoneHPACKQPACKround trips to first req221, or 0one big bundlehelpsnot needednot neededsprites and shardinghelphurthurt
Read the bottom two rows as the punch line: the tricks that helped on HTTP/1.1 stop helping, or start hurting, once requests share one connection.

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.js to see that it imports router.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; modulepreload can 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?

One big bundle

Bundle every module for every route into one app.js.

Hash the file name and cache it for a year.

Pros

  • Best compression ratio.
  • Simple build and deploy.
  • No import waterfalls.

Cons

  • Any change re-downloads everything.
  • Every page downloads and parses code for every other page.

Granular chunks

Split into a framework chunk, a few shared library chunks, one chunk per route, and lazy chunks.

Keep the initial load to roughly 10 to 30 files.

Preload the route chunk with modulepreload.

Pros

  • Deploys invalidate only what changed.
  • Each page loads only its own code.
  • Requests run in parallel on one connection.

Cons

  • Needs bundler tuning.
  • A little less compression than one file.

Unbundled native modules

Serve every source module as its own file and let the browser follow import statements.

Pros

  • Perfect cache granularity.
  • No build step for development.

Cons

  • Import waterfalls cost one round trip per level of depth.
  • Hundreds of requests and poor compression.
  • Slow on real mobile networks.

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

LevelWhat a strong answer includes
JuniorKnows 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.
MidExplains 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.
SeniorExplains 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-Svc or 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 with fetchpriority.

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?

Question 2

HTTP/2 solved head-of-line blocking. Why do we need HTTP/3?

Question 3

What is 0-RTT and what is the risk?

Question 4

How does a browser know a site supports HTTP/3?

Question 5

What happened to HTTP/2 server push, and what replaced it?

References

Read next