completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Connections in practice

Reuse connections, cut third-party origins, and make HTTP/2 and HTTP/3 prioritization work for you. Measured in Chrome over HTTP/2 on a slow link: a page with its font and library on other origins, host names that did and didn't share a connection, a script and a font that needed two, the Priority header on every request, a server that honoured it, and two sites that couldn't share a CDN copy.

By the end, you can
  • 01
    Count the connections a page needsOne for each origin, unless two host names can share one. A second one when some requests carry credentials and others don't.
  • 02
    Cut the extra onesSelf-host what you can. Put your own host names at one address, behind one certificate. Add crossorigin so a script matches a font.
  • 03
    Make priorities workRead the Priority header Chrome sends, nudge it with fetchpriority, and check that your server sends the urgent files first.

Fewer, shared connections

Asha opens the tea shop's spring sale page on her phone. The page is small. It has a heading in a handwritten font, one line of text, and a script library the shop uses. That makes four files: the page itself, a stylesheet that sets up the font, the font file, and the script.

Every file lives at an origin: the scheme, host name and port of its address, like https://shop.test. A different host name is a different origin. The shop could serve all four files from its own origin. That's called self-hosting.

But the shop's developer took a shortcut. They pasted one line from a font service, which brings in the stylesheet and the font. They pasted another from a public CDN: a server that hosts popular libraries for any site to link to. Now the files come from four different origins.

The bytes are the same either way. So does it matter where they come from? The course built both versions of the page and loaded each one 5 times in Chrome, on a slow line. Every message took 150 ms to reach the server and come back, as on Ravi's line in Latency vs bandwidth. Here is what it measured:

The same page, two waysPop out in the code editor (opens in a new tab)JavaScript
// The same sale page, two ways. LCP is the median of 5 runs, in ms.const selfHosted = { connections: 1, lcp: 1280 };const otherOrigins = { connections: 4, lcp: 1940 }; console.log(`Self-hosted: ${selfHosted.connections} connection, LCP at ${selfHosted.lcp} ms.`);console.log(`Font and library from other origins: ${otherOrigins.connections} connections, LCP at ${otherOrigins.lcp} ms.`);
  • Line 1 is a comment. The median is the middle value of the 5 runs. LCP, the largest contentful paint, is when the biggest thing on the screen was drawn. On this page, that's the heading in its handwritten font.
  • Line 2 is the self-hosted page. It opened 1 connection, and its LCP came at 1,280 ms.
  • Line 3 is the page with its font and library on other origins. It opened 4 connections, and its LCP came at 1,940 ms.
  • Lines 5 and 6 print Self-hosted: 1 connection, LCP at 1280 ms. and Font and library from other origins: 4 connections, LCP at 1940 ms.

Same bytes, but the heading appeared 660 ms later. The files didn't change. Only the number of places they came from did, and each place cost a connection. Here is the whole difference in one picture:

The connections each page opened, the same in every run, and its median LCP. Self-hosted: shop.test; LCP 1,280 ms. Font and library from other origins: shop.test, fonts.test, cdn.test, files.fonts.test; LCP 1,940 ms.Self-hosted: LCP 1,280 msshop.testFont and library from other origins: LCP 1,940 msshop.testfonts.testcdn.testfiles.fonts.test
One card for each connection, named for the host the browser asked for. Shaded: the page's own connection.

Why does each extra place cost so much? Think of a shopping trip.

Real-life analogyAnother shop, another queue

Your errand list says tea, bread and birthday candles. If one shop sold all three, you'd queue once and be done. Instead, each shop has its own door and its own queue, and you can't ask for anything until you reach the front. Worse, the baker tells you the candles are sold next door. That queue can't even start until you're done at the bakery.

In real life: Another shop on your list
In JavaScript: Another origin the page needs
In real life: Finding the shop, then queueing at its door
In JavaScript: A DNS lookup, then a new connection's handshakes
In real life: One shop that sells everything
In JavaScript: Every file on one origin: self-hosting
In real life: "The candles are sold next door"
In JavaScript: fonts.test's stylesheet, naming files.fonts.test
The main idea

Each origin a page uses needs its own connection. Every new connection spends round trips on handshakes before it can carry a single request. So use fewer origins, let your own host names share a connection, and make sure the server sends the most urgent bytes first.

The rest of the lesson takes this apart, one question at a time:

  • How much does each new origin cost, and why do some cost twice?
  • When can two host names share one connection?
  • Why does one origin sometimes need two connections?
  • Once files share a connection, whose bytes go first?
  • Is a public CDN's copy still worth a connection?

Every page the course recorded is in this playground. Pick one to see each connection it opened, and every file that went over it:

Playground: who shares a connection
What happened
  1. FCP1,076 ms
  2. LCP1,940 ms
  3. New connections4
  4. Connection 1, to shop.test/third-party (u=0, i)
  5. Connection 2, to fonts.test/css?family=Caveat (u=0)
  6. Connection 3, to cdn.test/lib.js (u=1)
  7. Connection 4, to files.fonts.test/caveat.woff2 (u=0)
Try it yourself
Font and library from other origins: LCP 1,940 ms

The same page, with the font stylesheet from fonts.test, the font from files.fonts.test and the library from cdn.test. Each of those lives at its own address. The times are medians of 5 runs. The connections and requests were the same in every run. The brackets hold each request's Priority header.

Recorded in Chrome 156 on Apple M1 Pro, over HTTP/2, on a line with a 150 ms round trip and 1.6 Mbps down. Host names were looked up instantly.

The first two rows are times. FCP, the first contentful paint, is when the first text or picture appeared. Then comes LCP. After that, there's one row for each connection, named for the host the browser asked for. The brackets hold each request's Priority header, which the priorities section explains.

The cost of each new origin

A connection leads to one origin. On the self-hosted page, every file came from https://shop.test, so one connection carried all four. On the other page, each new origin needed a connection of its own.

A new connection can't carry anything straight away. First it has to finish two handshakes: short exchanges of messages that set something up before any data flows. Module 4 counted them. TCP's hello takes one round trip, as TCP connections and slow start showed. TLS 1.3's secret code takes another, as in TLS without the wait. On this line, that comes to:

What a new connection costs firstPop out in the code editor (opens in a new tab)JavaScript
// Before a new connection can carry its first request, on this line.const roundTripMs = 150;const handshakes = 2; // TCP's hello, then TLS 1.3's secret code console.log(`One new connection: ${handshakes * roundTripMs} ms of handshakes.`);console.log(`Two new connections, one after the other: ${2 * handshakes * roundTripMs} ms.`);
  • Line 2 is the round trip on this line: 150 ms.
  • Line 3 counts the handshakes. Each one takes a round trip.
  • Line 5 multiplies the two and prints One new connection: 300 ms of handshakes.
  • Line 6 doubles that, for two connections opened one after the other. It prints Two new connections, one after the other: 600 ms.

You can see the first number in the recordings. In every setup, the page's own first byte came at about 479 ms. That's just over three round trips: two for the handshakes, and one to ask for the page and hear back.

But why would two new connections ever come one after the other? Because the browser can't connect to a host it hasn't heard of yet. Here is where it first read each host name on the page with other origins:

Where each host name came fromPop out in the code editor (opens in a new tab)JavaScript
// Where the browser first read each host name, on the page with other origins.const foundIn = {  "shop.test": "the address Asha opened",  "fonts.test": "named in the page's HTML",  "cdn.test": "named in the page's HTML",  "files.fonts.test": "named in the stylesheet from fonts.test",}; for (const [host, place] of Object.entries(foundIn)) console.log(`${host}: ${place}.`);
  • Line 3 is the page's own host. The browser knew it from the start.
  • Lines 4 and 5 are the font service and the CDN. The page's HTML names both, so the browser found them as soon as the page arrived.
  • Line 6 is the host the font file lives on. Only the stylesheet from fonts.test names it.
  • Line 9 prints one sentence for each host, like files.fonts.test: named in the stylesheet from fonts.test.

So the font waited in a chain. The browser opened a connection to fonts.test and downloaded the stylesheet. Only then did it learn about files.fonts.test, and open a second connection there. web.dev warns about this. A font provider "may serve stylesheets and fonts from separate origins", and its font files need "separate connection setups."

That's two new connections in a row: 600 ms of handshakes before the font could even be asked for. The heading came 660 ms later than on the self-hosted page.

Even the first paint moved, from 748 ms to 1,076 ms. The library is a plain script in the page's head. The browser stops reading the HTML until such a script has run, so nothing below it can be drawn. Render-blocking and parser-blocking resources showed why. Now that script came over a new connection, so the first paint waited for its handshakes too.

One cost is missing from these recordings. Here, every host name was looked up instantly, so no page paid for DNS. A real visitor's browser has to look up each new host name first, as DNS lookups showed. That's one more wait for every new origin.

Why it matters: every origin you add puts handshakes in front of its files. When a file is needed for the first view, like a font or a blocking script, those handshakes land straight on FCP or LCP. The simplest fix is to serve those files from your own origin.

Connection reuse and coalescing

The self-hosted page shows the simplest kind of sharing. Its one connection to shop.test carried the page, then the stylesheet, the script and the font. That's connection reuse. Once a connection is open, later requests to the same origin use it, and skip the handshakes. It's the phone call from TCP connections and slow start that stays connected, so you can ask all your questions on one call.

Now a harder case. A shop may serve its pictures from a second host name of its own, like img.shop.test. That's a different origin. Does it need a new connection?

Not always. A browser may send a second host name's requests over a connection it already has. That's connection coalescing. HTTP/1.1 and HTTP/2 pictured it as two shops behind one door. RFC 9113, the HTTP/2 standard, allows it. For an https:// address, the browser checks two things:

  • The host name leads to the same IP address as the open connection. An IP address is the number computers use to find each other.
  • The open connection's certificate is valid for the host name. A certificate is the server's proof of the names it may answer for. In RFC 9113's words, reuse "additionally depends on having a certificate that is valid for the host in the URI."
Real-life analogyOne supermarket, many counters

The tea shop has grown into a supermarket. Its tea counter and its bakery counter are in the same building, and the sign at the entrance names them both. Once you're inside, you just walk from one counter to the other. But a counter with its own street door and its own sign means going back out and queueing again. So does the branch across town, even though the main sign lists it too.

In real life: The supermarket's building
In JavaScript: One IP address
In real life: The sign at the entrance, naming every counter
In JavaScript: The shop's certificate, naming img.shop.test and media.shop.test
In real life: Walking to the bakery counter inside
In JavaScript: img.shop.test's request, sent on the page's open connection
In real life: A counter with its own street door and sign
In JavaScript: static.shop.test, with a certificate of its own
In real life: The branch across town
In JavaScript: media.shop.test, at another address

The course put this to the test. It ran each address as a server of its own, and served the page's hero, the big photo at the top, from three host names. On these pages, the hero is the biggest thing on the screen, so LCP waits for it. Read each row as two questions: is it in the same building, and is it on the sign?

Three host names for the hero photo
Host nameSame address as shop.test?Named on the shop's certificate?
img.shop.testYes.Yes.
static.shop.testYes.No. It has a certificate of its own.
media.shop.testNo. It lives at another address.Yes.

Here are the first two, as the course measured them:

Shared or notPop out in the code editor (opens in a new tab)JavaScript
// The hero photo from two other host names. LCP is the median of 5 runs, in ms.const fromImg = { connections: 1, lcp: 1160 };const fromStatic = { connections: 2, lcp: 1488 }; console.log(`Hero from img.shop.test: ${fromImg.connections} connection in all, LCP at ${fromImg.lcp} ms.`);console.log(`Hero from static.shop.test: ${fromStatic.connections} connections in all, LCP at ${fromStatic.lcp} ms.`);
  • Line 2 is img.shop.test. The page opened 1 connection in all, so the hero came over the page's own connection.
  • Line 3 is static.shop.test. The page opened 2 connections: its own, and one more just for the hero.
  • Lines 5 and 6 print Hero from img.shop.test: 1 connection in all, LCP at 1160 ms. and Hero from static.shop.test: 2 connections in all, LCP at 1488 ms.

The shared connection showed the hero 328 ms sooner. That's about the 300 ms of handshakes a new connection costs on this line.

The connections each page opened, the same in every run, and its median LCP. img. on the same address and certificate: shop.test; LCP 1,160 ms. static. with its own certificate: shop.test, static.shop.test; LCP 1,488 ms. media. on another address: shop.test, media.shop.test; LCP 1,484 ms.img. on the same address and certificate: LCP 1,160 msshop.testthe hero came over this onestatic. with its own certificate: LCP 1,488 msshop.teststatic.shop.testmedia. on another address: LCP 1,484 msshop.testmedia.shop.test
Shaded: the page's own connection. A second card means the hero needed a connection of its own.

The picture's third row is media.shop.test. The shop's certificate names it, but it lives at another address. One missing condition was enough: it needed a connection of its own too.

Why it matters: if you serve files from host names of your own, put them at the same address, behind one certificate that names them all. Then they cost no new connection. A font service or a public CDN can't join in this way, because your certificate doesn't name its host.

crossorigin and separate connection pools

Here's a surprise: one origin doesn't always mean one connection. The course loaded a page that takes a font and a script from cdn.test:

Two files from one originHTML
<style>  @font-face { font-family: Caveat; src: url(https://cdn.test/caveat.woff2) format("woff2"); font-display: block; }</style><script src="https://cdn.test/lib.js"></script>
  • Line 2 tells the browser where the font lives: on cdn.test.
  • Line 4 loads the script from cdn.test too. It's a plain <script> tag.

The browser opened two connections to cdn.test: one for the script, and one for the font. Why didn't they share?

Because a connection carries more than one label. The Fetch Standard, which describes how browsers fetch, says: "Each connection is identified by an associated key (a network partition key), origin (an origin), and credentials (a boolean)." A boolean is a true-or-false value. So every connection is labelled with three things:

  • the site the page is on, called its network partition key (the last section comes back to it);
  • the origin it leads to;
  • whether its requests carry credentials.

Credentials are things that tell a server who you are, like cookies. A request either carries them or it doesn't. A request with credentials can only use a connection labelled "with". A request without them can only use one labelled "without". Now look at the two files again:

  • A plain script tag asks with credentials. The HTML standard sets its request's credentials mode to "include".
  • A font asks without them. The CSS standards fetch every font in CORS mode, with credentials only for the page's own origin. CORS, cross-origin resource sharing, is the set of rules that lets a server say which other origins may read its files.

So the script needed a connection "with", and the font needed one "without". Two labels, two connections.

Real-life analogyA members' door and a guests' door

A wholesale shop has two doors. Members show their card at one. Guests, with no card, go in at the other. Each door has its own queue. You can only share a trip with a friend if you both use the same door. If you show your card and your friend has none, you queue twice at the same shop.

In real life: The members' door, where you show your card
In JavaScript: A connection for requests with credentials
In real life: The guests' door, with no card
In JavaScript: A connection for requests without credentials
In real life: You, showing your card
In JavaScript: The plain script tag
In real life: Your friend, who never has a card
In JavaScript: The font
In real life: Leaving your card in your pocket
In JavaScript: Adding crossorigin to the script

The fix is one word. The crossorigin attribute asks for the script the way a font is asked for: in CORS mode, with no credentials for another origin.

The script, with crossoriginHTML
<script src="https://cdn.test/lib.js" crossorigin></script>
  • Line 1 is the same script tag, with crossorigin added. Now the script and the font carry the same label.

The course loaded both versions of the page:

Two connections, or onePop out in the code editor (opens in a new tab)JavaScript
// A font and a script from cdn.test. LCP is the median of 5 runs, in ms.const plainScript = { toCdn: 2, lcp: 1920 };const withCrossorigin = { toCdn: 1, lcp: 1596 }; console.log(`Plain script: ${plainScript.toCdn} connections to cdn.test, LCP at ${plainScript.lcp} ms.`);console.log(`Script with crossorigin: ${withCrossorigin.toCdn} connection to cdn.test, LCP at ${withCrossorigin.lcp} ms.`);
  • Line 2 is the plain script: 2 connections to cdn.test, and LCP at 1,920 ms.
  • Line 3 is the script with crossorigin: 1 connection to cdn.test, and LCP at 1,596 ms.
  • Lines 5 and 6 print Plain script: 2 connections to cdn.test, LCP at 1920 ms. and Script with crossorigin: 1 connection to cdn.test, LCP at 1596 ms.
The connections each version of the page opened, the same in every run, and its median LCP. Script and font from cdn.test: shop.test (the page), cdn.test: lib.js (with credentials), cdn.test: caveat.woff2 (without credentials); LCP 1,920 ms. The same, script with crossorigin: shop.test (the page), cdn.test: lib.js, caveat.woff2 (without credentials); LCP 1,596 ms.Script and font from cdn.test: LCP 1,920 msshop.testthe pagecdn.test: lib.jswith credentialscdn.test: caveat.woff2without credentialsThe same, script with crossorigin: LCP 1,596 msshop.testthe pagecdn.test: lib.js, caveat.woff2without credentials
Shaded: a connection without credentials. With crossorigin, the script joined the font on it.

The heading appeared 324 ms sooner. That's close to the 300 ms of handshakes the font no longer had to wait for: it used the connection the script had already opened.

Two things to check before you add it:

  • The server has to allow it. A CORS request needs an Access-Control-Allow-Origin header in the answer, or the browser won't use the file. cdn.test sent Access-Control-Allow-Origin: *, which allows every origin.
  • The script's request now goes without cookies. If the server needs them to answer, the script needs its connection "with".

Why it matters: the same labels explain an easy mistake with preconnect, a hint that opens a connection early. The HTML standard opens a preconnect's connection "with" credentials unless it has crossorigin. So a preconnect for a font host needs crossorigin, or the font can't use it. Resource hints, next, shows it.

Extensible priorities

Now say all of a page's files share one connection. Good. But the line still has one speed. When several files are on their way at once, whose bytes go first?

The server decides, because it's the one sending. But it can't see the page, so the browser tells it what matters most. HTTP/1.1 and HTTP/2 pictured this as telling a café kitchen "the tea first, the cake last." Today Chrome says it with a Priority header on each request.

The header comes from RFC 9218, the Extensible Prioritization Scheme. Its format is "designed to provide future extensibility": more parameters can be added later. RFC 9218 itself defines two:

  • u is the urgency: a number from 0 to 7. The smaller the number, the sooner the file is wanted. RFC 9218 adds: "The default is 3."
  • i means incremental: the file is useful in pieces. The RFC says such a file can "provide some meaningful output as chunks of the response arrive." Without i, the browser only uses the file once it's complete.

The course built a gallery page to see these headers. It has the sale heading, a stylesheet, a font, a hero photo marked fetchpriority="high", four more photos, and two small scripts. Here is what Chrome sent:

What Chrome asked forPop out in the code editor (opens in a new tab)JavaScript
// The Priority header Chrome sent with the gallery's files, the same in every run.const sent = {  "the page": "u=0, i",  "stylesheet": "u=0",  "font": "u=0",  "hero photo, fetchpriority=high": "u=1, i",  "other photos": "u=2, i",}; for (const [file, priority] of Object.entries(sent)) console.log(`${file}: ${priority}`);
  • Line 2 starts a list of the gallery's files, each with the header Chrome sent. They were the same in every run.
  • Lines 3 to 5 are the page, its stylesheet and its font. All three got urgency 0, the most urgent. The page is also marked i.
  • Lines 6 and 7 are the photos. The hero got urgency 1, and the other four got 2. All of them are marked i.
  • Line 10 prints one line for each file, from the page: u=0, i to other photos: u=2, i.

Chrome picks these numbers itself, the way How browsers prioritize requests showed. You don't have to set anything up. Two files went without a header at all:

No header at allPop out in the code editor (opens in a new tab)JavaScript
// Two scripts on the gallery page. Chrome sent no Priority header with either.const sent = {  "app.js, defer": null,  "analytics.js, async": null,}; for (const [file, priority] of Object.entries(sent)) console.log(`${file}: ${priority ?? "no header, so urgency 3"}`);
  • Lines 3 and 4 are the gallery's two scripts, one loaded with defer and one with async. The value null means no header was sent.
  • Line 7 prints each one. When the value is null, ?? puts in the words instead. It prints app.js, defer: no header, so urgency 3 and analytics.js, async: no header, so urgency 3.

No header means the defaults. RFC 9218 says a server "SHOULD act as if their default values were specified." So the two scripts sit at urgency 3, behind every photo.

But a header is only a request. RFC 9218 is plain about it: "Expressing priority is only a suggestion." It recommends that servers send "higher-urgency responses before lower-urgency responses." A server doesn't have to.

Real-life analogyFilling cups from one teapot

Five friends are waiting for tea, and you have one teapot. You could pour a splash into each cup in turn. Then all five cups fill slowly together, and nobody can drink until near the end. Or you could fill the cups of the friends in a hurry first, then pour for the rest.

In real life: The one teapot, pouring at its own speed
In JavaScript: One connection, at the line's speed
In real life: A splash in each cup, in turn
In JavaScript: A server that sends a piece of each file in turn
In real life: Filling the hurried friends' cups first
In JavaScript: A server that sends by urgency, as RFC 9218 recommends
In real life: The friends in a hurry
In JavaScript: Files with a small urgency number, like the hero photo

The course built two servers for the gallery to see the difference. Both got the same requests, with the same headers. Both sent each file in pieces of 16 KB, one piece whenever the line had room. They differed only in which file's piece went next:

  • The first shared the line evenly. It took the waiting files in turn, a piece of each.
  • The second followed RFC 9218. It sent the files with the smallest urgency number first. Files at the same urgency marked i took turns.
// The gallery's LCP, median of 5 runs, in ms. Same requests, same headers, two servers.const sharesEvenly = 2572;const sendsByUrgency = 1608; console.log(`Server that shares the line evenly: LCP at ${sharesEvenly} ms.`);console.log(`Server that sends by urgency: LCP at ${sendsByUrgency} ms.`);
  • Line 2 is the server that shared the line evenly: LCP at 2,572 ms.
  • Line 3 is the server that sent by urgency: LCP at 1,608 ms.
  • Lines 5 and 6 print Server that shares the line evenly: LCP at 2572 ms. and Server that sends by urgency: LCP at 1608 ms.
The gallery's median LCP of 5 runs. Gallery, server shares evenly: 2,572 ms. Gallery, server sends by urgency: 1,608 ms.Gallery, server shares evenly2,572 msGallery, server sends by urgency1,608 ms
Shaded: the server that sent by urgency. Both servers got the same requests, with the same Priority headers.

Same browser, same requests, same headers. The hero appeared 964 ms sooner, only because the server sent it before the other photos. With even sharing, the hero shared the line with every other waiting file, a piece at a time, so it finished late.

Why it matters: Chrome already sends these hints by itself. Whether they help depends on your server or CDN, so check that it acts on the Priority header. If the browser guesses wrong about a file, correct it with fetchpriority. The header isn't tied to HTTP/2 either. RFC 9218 calls it a signal "independent of protocol version", so it works over HTTP/3 too.

Self-hosting vs third-party CDNs

Back to the shop's shortcut. A file from a font service or a public CDN is third-party: it comes from someone else's origin. There used to be a good reason to link one.

Chrome used to file each cached copy under its URL alone. So if many sites linked the same library, a visitor who had been to one of them already had it. Then Chrome split its cache by site. The browser's caches pictured that as each school keeping its own lost-property box. Chrome's post on the change warned that services sharing "fonts and popular scripts" across many sites "may see an increase in their traffic."

Connections are split the same way. Remember the first label on every connection, its network partition key? The Fetch Standard defines it as "a tuple consisting of a site and null or an implementation-defined value." A tuple is a short list of values. In plain words, the site you're on is part of the label.

The course tested it with one browser. It visited site A, then site B. Both pages load https://cdn.test/lib.js, and no connection was closed in between:

Two sites, one filePop out in the code editor (opens in a new tab)JavaScript
// Site A, then site B, in one browser, with no connection closed in between.// Both load https://cdn.test/lib.js. What reached cdn.test, the same in every run:const siteA = { requests: 1, newConnections: 1 };const siteB = { requests: 1, newConnections: 1 }; console.log(`Site A: ${siteA.requests} request for lib.js, ${siteA.newConnections} new connection to cdn.test.`);console.log(`Site B: ${siteB.requests} request for lib.js, ${siteB.newConnections} new connection to cdn.test.`);
  • Lines 1 and 2 are comments: what happened, and what was counted at cdn.test.
  • Line 3 is site A: 1 request for lib.js reached cdn.test, over 1 new connection.
  • Line 4 is site B: exactly the same.
  • Lines 6 and 7 print Site A: 1 request for lib.js, 1 new connection to cdn.test. and Site B: 1 request for lib.js, 1 new connection to cdn.test.

Site B got nothing from site A's visit: not the file, and not the connection. It asked cdn.test again, over a new connection of its own. So for a library, a public CDN's copy saves nothing any more. Self-hosting it loses no cache hits, and it saves a connection.

web.dev adds a warning about fonts: "In practice, the performance differences between these two options is less clear cut." Before you self-host fonts, it says to "confirm that your site uses a Content Delivery Network (CDN) and HTTP/2." Then measure. Compare "the transfer time (including connection setup)" of your own copy with the font service's.

Why it matters: a third-party file costs a connection, and another site's copy no longer saves the download. Self-host it, serve it from a CDN of your own, and check the result.

What would you change?

Each case below costs a connection the page doesn't need. Sort it by the change that saves it:

What would you change?
  • A web font from a font service
  • A library from a public CDN
  • A script and a font from your own CDN host, with a plain script tag
  • A preconnect to the host your font files come from
  • img.example.com on a certificate of its own, at the same address as www
  • static.example.com at another IP address, with a certificate that names both
Try it yourself
0 of 6 correct

Sort each case by the change that saves a connection.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A popular CDN's copy is probably in the cache already." No. Caches are split by site. Site B downloaded lib.js again, over a new connection.
  • "My own subdomains are free." No. static.shop.test had a certificate of its own, so it needed a connection of its own. LCP came at 1,488 ms instead of 1,160.
  • "One origin means one connection." No. A plain script and a font from cdn.test needed two: one with credentials, one without.
  • "The browser decides the order bytes arrive in." No. The browser only asks. The server decides. The same gallery had its LCP at 2,572 ms or 1,608, depending on the server.
  • "I have to set up priorities before Chrome sends them." No. Chrome sent a Priority header with the gallery's files by itself. Only the defer and async scripts went without one.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upAnother address

The course also served the hero from media.shop.test. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// The hero photo from two other host names. LCP is the median of 5 runs, in ms.
const fromImg = { connections: 1, lcp: 1160 };
const fromStatic = { connections: 2, lcp: 1488 };
const fromMedia = { connections: 2, lcp: 1484 };

console.log(`Hero from img.shop.test: ${fromImg.connections} connection in all, LCP at ${fromImg.lcp} ms.`);
console.log(`Hero from static.shop.test: ${fromStatic.connections} connections in all, LCP at ${fromStatic.lcp} ms.`);
console.log(`Hero from media.shop.test: ${fromMedia.connections} connections in all, LCP at ${fromMedia.lcp} ms.`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upA script in the head

    Line 8 adds a plain script from the self-hosted page's head, with the header Chrome sent. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // The Priority header Chrome sent with the gallery's files, the same in every run.
    const sent = {
      "the page": "u=0, i",
      "stylesheet": "u=0",
      "font": "u=0",
      "hero photo, fetchpriority=high": "u=1, i",
      "other photos": "u=2, i",
      "plain script in the head": "u=1",
    };
    
    for (const [file, priority] of Object.entries(sent)) console.log(`${file}: ${priority}`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeOne connection, many files

      The gallery opened just one connection. How many requests went over it?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeSite B

        What was site B's FCP?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeNot this one

          A server doesn't want a request that came over a shared connection. Which status code does it answer with?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengePartitioned since

            Starting with which version did Chrome split its HTTP cache by site?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask three things: how many connections each request needed, which requests could share one, and in what order the server sent the bytes.

              Connections in practice quiz · 6 questionsScore: first tries count
              1. Question 1 of 6What does the second line of this example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The same sale page, two ways. LCP is the median of 5 runs, in ms.
                const selfHosted = { connections: 1, lcp: 1280 };
                const otherOrigins = { connections: 4, lcp: 1940 };
                
                console.log(`Self-hosted: ${selfHosted.connections} connection, LCP at ${selfHosted.lcp} ms.`);
                console.log(`Font and library from other origins: ${otherOrigins.connections} connections, LCP at ${otherOrigins.lcp} ms.`);

                Choose an answer to see the explanation.

              2. Question 2 of 6What does the first line of this example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The hero photo from two other host names. LCP is the median of 5 runs, in ms.
                const fromImg = { connections: 1, lcp: 1160 };
                const fromStatic = { connections: 2, lcp: 1488 };
                
                console.log(`Hero from img.shop.test: ${fromImg.connections} connection in all, LCP at ${fromImg.lcp} ms.`);
                console.log(`Hero from static.shop.test: ${fromStatic.connections} connections in all, LCP at ${fromStatic.lcp} ms.`);

                Choose an answer to see the explanation.

              3. Question 3 of 6static.shop.test was at the shop's own address, but it didn't share the page's connection. Why?

                Choose an answer to see the explanation.

              4. Question 4 of 6Why did crossorigin on the script save a connection to cdn.test?

                Choose an answer to see the explanation.

              5. Question 5 of 6The two gallery servers got the same requests and the same Priority headers. Why did one show the hero much sooner?

                Choose an answer to see the explanation.

              6. Question 6 of 6Site B loaded the same CDN file as site A, in the same browser. Why did it download it again?

                Choose an answer to see the explanation.

              Key takeaways

              • Each origin costs a connection, and each new connection spends two round trips on handshakes. With its font and library on other origins, the sale page's LCP went from 1,280 ms to 1,940.
              • A host name can share a connection only at the same address, with a certificate that names it.
              • Requests with and without credentials need separate connections. Add crossorigin so a script matches a font.
              • Chrome sends a Priority header with most requests. Make sure your server or CDN acts on it.
              • Caches and connections are split by site, so a public CDN's copy is never shared. Self-host what you can, then measure.

              Remember the one-liner.
              Fewer origins, shared connections, and a server that sends the urgent bytes first.

              Sources

              The facts on this page come from these standards and docs:

              Coming next: Resource hints, also in Fast servers, caches, and networks. dns-prefetch, preconnect, preload, modulepreload and prefetch: what each does, and when it backfires.

              CompleteFrontend Clear concepts. Working examples.