completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Loading performance

Learn how to reduce JavaScript bytes, split code, choose async or defer, cache smarter, and protect Core Web Vitals on real devices.

By the end, you can
  • 01
    Set honest size limits for JavaScriptTell the download size apart from the parse, compile, and execute work, then check each route and chunk against a budget.
  • 02
    Choose how and when code loadsPick between one chunk per route, import() on a click, resource hints, lazy images, async, defer, and module scripts.
  • 03
    Protect what real users feelSee how loading choices change LCP, INP, and CLS, use caches for repeat visits, and check lab tests against real-user data.

Send less, start sooner

When you open a web page, the browser must download files and run code before you can use the page. Loading performance is about making that wait short. The main way to do it is simple: make the browser do less work before the page is useful.

Here are three ways to save time, best first:

  1. Don't send code the page doesn't need.
  2. Send code later, at the moment the user needs it.
  3. Let the browser keep code and reuse it on the next visit.
The main idea

Loading performance means keeping three things small:

  • The files the page must have before it can show anything useful: JavaScript, CSS, images, and data. The page waits for these files, so they are on the critical path.
  • The time before the browser finds out about each file. It can't download a file it doesn't know about yet.
  • The work on the main thread before the page is drawn and can respond. The main thread is the one worker that runs your JavaScript, draws the page, and handles clicks.
Real-life analogyPacking for a trip

You don't carry winter boots, a toolbox, and a spare screen onto every flight. Your carry-on holds what the first day needs. Bulky things go in a checked bag. Some things are already waiting at the hotel. Loading performance asks the same questions about code.

In real life: Your carry-on bag
In JavaScript: The JavaScript the first page needs right away
In real life: Your checked bag
In JavaScript: Code that loads later, after a click or a move to another page
In real life: Your packing list
In JavaScript: A report of every code file and its size, with a size limit
In real life: Things already waiting at the hotel
In JavaScript: Copies the browser kept from your last visit

This lesson builds on bundlers, dynamic import, page lifecycle, Cache API and file storage, service workers, and engines and runtimes. Two later lessons go further: measuring performance and keeping the main thread responsive.

The cost of JavaScript

BYTES + CPU

A JavaScript file costs more than its download. The server usually sends it compressed: squeezed smaller, like a zip file. Then the JavaScript engine, the part of the browser that runs JavaScript, has more work to do. In order, the browser must:

  1. Download the compressed file.
  2. Decompress it back into the full source text.
  3. Parse it: read the text and work out how the code is built.
  4. Compile it: turn it into instructions the computer can run.
  5. Execute it: run the code.
Five jobs in a row for one script: download, decompress, parse, compile, execute. 82 KB is sent over the network. After decompressing, the engine works on 250 KB of source text.82 KB sent250 KB of source textdownloaddecompressparsecompileexecutenetworkCPU work on the phonetime
Only the first job is the download. The other four make the phone's CPU work on the full 250 KB of text before the page can respond.

An image can be bigger than a script. But byte for byte, a script usually makes the phone's processor (the CPU) work much harder before the page responds.

The table below shows the five jobs. Read each row as: "at this job, the browser pays this."

What one JavaScript file costs, job by job
Which job?What does it cost?
DownloadThe compressed file travels over the network. Brotli or gzip makes it smaller, but the whole file must arrive before it can run.
DecompressThe file grows back into the full source text. So track two sizes: the compressed size and the uncompressed size.
ParseThe engine reads the text and works out how the code is built. More text means more reading before the code can run.
CompileEngines such as V8 turn the code into instructions the computer can run. On a repeat visit, they may reuse saved instructions.
ExecuteYour code runs, on the main thread unless you move it to a worker. Start-up code and hydration can make clicks wait.

How much JavaScript do real pages send? The 2024 Web Almanac JavaScript chapter answers that. It was published on March 3, 2025, from pages measured in June 2024. It found:

  • The middle page (the median) sends 558 KB of JavaScript on mobile and 613 KB on desktop.
  • JavaScript on mobile pages grew by 14% in one year.

web.dev's guide to JavaScript start-up adds a warning. An average phone can have a slow CPU, a small cache, and little free memory. So a fast office laptop doesn't show what most users see.

Track two sizes, then the time

Track the compressed size, because that is what travels over the network. Track the uncompressed size, because that is what the engine parses. Then check the real parse, compile, and execute times in the browser's Performance panel. Don't guess the time from the size alone.

Chrome's Network panel shows two sizes for each file:

  • The transferred size is the compressed file that came over the network.
  • The resource size is the file after decompression. It is close to the text the engine parses.

The numbers below come from one measured script. Other scripts shrink by different amounts.

Two sizes for one scriptPop out in the code editor (opens in a new tab)JavaScript
const script = { transferredKb: 82, resourceKb: 250 };
console.log("Transferred: " + script.transferredKb + " KB");
console.log("Resource: " + script.resourceKb + " KB");

Line 1 stores the two measured sizes. Line 2 prints Transferred: 82 KB. Line 3 prints Resource: 250 KB. The browser downloads 82 KB but parses 250 KB. So a small download doesn't mean cheap parsing.

How do the browser and the server agree on compression? With HTTP headers: short notes sent along with a request and with a response.

Browser and server agree on compressionHTTP
GET /assets/app.js
Accept-Encoding: br, gzip

HTTP/2 200
Content-Encoding: br
Vary: Accept-Encoding

Line by line:

  • Line 1: the browser asks for app.js.
  • Line 2: Accept-Encoding lists the formats the browser can decompress: Brotli (br) or gzip.
  • Line 4: the server answers. 200 means "OK, here is the file".
  • Line 5: Content-Encoding: br says the server picked Brotli.
  • Line 6: Vary: Accept-Encoding is for shared caches: stores between you and the server that keep copies. It tells them this URL has a different copy for each format.

Compression makes text files like JavaScript smaller to send. But parsing still starts from the full, decompressed text.

Teams keep an eye on this with a bundle analyzer: a tool that lists every JavaScript file the build makes, with its size. Each file is called a chunk. A good habit looks like this:

  1. List every chunk: the ones the page loads at the start, and the ones it loads later (lazy chunks).
  2. Mark whether each chunk is needed for the current page.
  3. Set a size limit, called a budget.
  4. Stop a code change from being merged when it goes over the budget, unless the team agrees it's worth it.

A real budget checker

STEP THROUGH

A budget is easy to check with a few lines of code. A route is one page of an app with its own address, like /home or /shop. Here are two routes checked against one budget.

Check two routes against one budgetPop out in the code editor (opens in a new tab)JavaScript
const budgetKb = 140;
const routes = [{ name: "home", initialKb: 132 }, { name: "shop", initialKb: 145 }];
for (const route of routes) {
  const result = route.initialKb <= budgetKb ? "within budget" : "over by " + (route.initialKb - budgetKb) + " KB";
  console.log(route.name + ": " + result);
}

Line 1 sets the budget: 140 KB. Line 2 lists two routes and how much JavaScript each one needs at the start. Lines 3 to 6 compare each route with the budget and print the result:

  • The home route needs 132 KB, so it prints home: within budget.
  • The shop route needs 145 KB, so it prints shop: over by 5 KB, because 145 − 140 = 5.
A budget of 140 KB. Left: the home route needs 132 KB, so it is within budget. Right: the shop route needs 145 KB, so it is over by 5 KB.budget: 140 KBhome: 132 KBwithin budgetshop: 145 KBover by 5 KB
Both routes are checked against the same 140 KB budget. Home fits, and shop is 5 KB too big.

Your team picks these limits; the browser doesn't set them. It helps to track three separate limits:

  • the JavaScript a route needs at the start,
  • the biggest lazy chunk, which loads later,
  • all of the route's JavaScript added together.

The next function checks all three. It is small, but it is real code that runs. It takes two things:

  • a list of chunks. Each chunk has a compressed size, an uncompressed size, and initial: whether it loads at the start.
  • a budget: the three limits, plus a guess of how fast a phone parses and compiles.

It adds up the sizes, guesses the parse and compile time, and reports exactly which limits failed. Press Step to watch it work.

Budget checker: find the chunks that are too big
Step 0 of 10Ready
Your turn: follow the blue line

Step through a real JavaScript budget checker. It adds up the compressed sizes, guesses the parse and compile time from the uncompressed sizes, and reports each limit that fails.

Running in
  1. script
Next: line 48
function checkJavaScriptBudget(chunks, budget) {  const initialCompressedKb = chunks    .filter((chunk) => chunk.initial)    .reduce((total, chunk) => total + chunk.compressedKb, 0);  const asyncCompressedKb = chunks    .filter((chunk) => !chunk.initial)    .reduce((total, chunk) => total + chunk.compressedKb, 0);  const totalCompressedKb = chunks    .reduce((total, chunk) => total + chunk.compressedKb, 0);  const totalUncompressedKb = chunks    .reduce((total, chunk) => total + chunk.uncompressedKb, 0);  const overBudget = [];   if (initialCompressedKb > budget.maxInitialCompressedKb) {    overBudget.push({      name: "initial total",      overByKb: initialCompressedKb - budget.maxInitialCompressedKb,    });  }   for (const chunk of chunks) {    if (!chunk.initial && chunk.compressedKb > budget.maxAsyncChunkCompressedKb) {      overBudget.push({        name: chunk.name,        overByKb: chunk.compressedKb - budget.maxAsyncChunkCompressedKb,      });    }  }   if (totalCompressedKb > budget.maxTotalCompressedKb) {    overBudget.push({      name: "total JavaScript",      overByKb: totalCompressedKb - budget.maxTotalCompressedKb,    });  }   return {    initialCompressedKb,    asyncCompressedKb,    totalCompressedKb,    estimatedParseCompileMs: Math.round(      totalUncompressedKb * budget.mobileParseCompileMsPerKb,    ),    overBudget,  };}   { name: "app-shell", compressedKb: 82, uncompressedKb: 250, initial: true },  { name: "product-route", compressedKb: 63, uncompressedKb: 210, initial: true },  { name: "charting-library", compressedKb: 118, uncompressedKb: 410, initial: false },]; const budget = {  maxInitialCompressedKb: 140,  maxAsyncChunkCompressedKb: 90,  maxTotalCompressedKb: 260,  mobileParseCompileMsPerKb: 0.2,}; const report = checkJavaScriptBudget(chunks, budget);console.log(  "initial " + report.initialCompressedKb + " KB, async " + report.asyncCompressedKb + " KB",);console.log(  "over budget: " + report.overBudget    .map((item) => item.name + " +" + item.overByKb + " KB")    .join("; "),);console.log("estimated parse/compile " + report.estimatedParseCompileMs + " ms");
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
The full budget checker, ready to runPop out in the code editor (opens in a new tab)JavaScript
function checkJavaScriptBudget(chunks, budget) {  const initialCompressedKb = chunks    .filter((chunk) => chunk.initial)    .reduce((total, chunk) => total + chunk.compressedKb, 0);  const asyncCompressedKb = chunks    .filter((chunk) => !chunk.initial)    .reduce((total, chunk) => total + chunk.compressedKb, 0);  const totalCompressedKb = chunks    .reduce((total, chunk) => total + chunk.compressedKb, 0);  const totalUncompressedKb = chunks    .reduce((total, chunk) => total + chunk.uncompressedKb, 0);  const overBudget = [];   if (initialCompressedKb > budget.maxInitialCompressedKb) {    overBudget.push({      name: "initial total",      overByKb: initialCompressedKb - budget.maxInitialCompressedKb,    });  }   for (const chunk of chunks) {    if (!chunk.initial && chunk.compressedKb > budget.maxAsyncChunkCompressedKb) {      overBudget.push({        name: chunk.name,        overByKb: chunk.compressedKb - budget.maxAsyncChunkCompressedKb,      });    }  }   if (totalCompressedKb > budget.maxTotalCompressedKb) {    overBudget.push({      name: "total JavaScript",      overByKb: totalCompressedKb - budget.maxTotalCompressedKb,    });  }   return {    initialCompressedKb,    asyncCompressedKb,    totalCompressedKb,    estimatedParseCompileMs: Math.round(      totalUncompressedKb * budget.mobileParseCompileMsPerKb,    ),    overBudget,  };} const chunks = [  { name: "app-shell", compressedKb: 82, uncompressedKb: 250, initial: true },  { name: "product-route", compressedKb: 63, uncompressedKb: 210, initial: true },  { name: "charting-library", compressedKb: 118, uncompressedKb: 410, initial: false },]; const budget = {  maxInitialCompressedKb: 140,  maxAsyncChunkCompressedKb: 90,  maxTotalCompressedKb: 260,  mobileParseCompileMsPerKb: 0.2,}; const report = checkJavaScriptBudget(chunks, budget);console.log(  "initial " + report.initialCompressedKb + " KB, async " + report.asyncCompressedKb + " KB",);console.log(  "over budget: " + report.overBudget    .map((item) => item.name + " +" + item.overByKb + " KB")    .join("; "),);console.log("estimated parse/compile " + report.estimatedParseCompileMs + " ms");

Lines 1 to 46 are the checker. Line 48 lists three chunks: two load at the start, and the chart library loads later. Line 54 sets the limits: 140 KB at the start, 90 KB for any lazy chunk, and 260 KB in total. Line 61 runs the checker. Lines 62 to 70 print three lines:

  • initial 145 KB, async 118 KB: 82 + 63 at the start, 118 later.
  • over budget: initial total +5 KB; charting-library +28 KB; total JavaScript +3 KB: all three limits failed.
  • estimated parse/compile 174 ms: 870 uncompressed KB × 0.2 ms per KB.

In a real project, you would feed this function from a file your bundler writes, which lists each chunk and its size. This site is built with Next.js, and each route has its own chunks. The sizes in this lesson are examples, not this site's real sizes.

Code splitting and lazy loading

ROUTES + INTERACTIONS

Code splitting means breaking your JavaScript into several chunks, so a page downloads only what it needs. Route-level splitting gives each route its own chunk. Then the first page doesn't download the code for every other page.

Frameworks such as Next.js make one chunk per route by default. Bundlers also make a new chunk wherever they see import(). The question to ask is: does the user need this code for the first thing they do on the page?

Load a chart only after the user asksPop out in the code editor (opens in a new tab)JavaScript
const button = document.querySelector("#load-chart");const output = document.querySelector("#chart-output"); button.addEventListener("click", async () => {  button.disabled = true;  output.textContent = "Loading chart code...";  try {    const chart = await import("./chart.js");    chart.renderChart(output);  } catch {    output.textContent = "Chart could not load. Try again.";  } finally {    button.disabled = false;  }});

Line 4 waits for a click on the button. Line 8 asks for the chart code: import() downloads chart.js and waits for it. Line 9 runs the loaded code, which writes the chart text. If the download fails, line 11 shows a message, and line 13 turns the button back on so the user can try again.

A page window with a Load chart button and no chart code yet. A click reaches import(), and an arrow leads to the chart.js file, which is downloaded only now.the pageLoad chartno chart code yetimport()on clickchart.jsdownloaded now
The first load sends no chart code. The click runs import(), and only then does the browser download chart.js.

This is not the same as hiding work behind a timer. The browser doesn't even start to download chart.js until the click handler reaches import(). The dynamic import lesson explains the promise that import() returns and the module object it gives you.

Sometimes you want the opposite: the browser should start a download earlier than it would by itself. A resource hint is a <link> tag in the page that asks for that. Three hints look alike but do different jobs. Read each row as: "this hint asks for this, it helps this page, and watch out for this."

Three resource hints that look alike but do different jobs
Which hint?What does it ask for?Which page does it help?What should you watch out for?
preloadA file this page needs soon.This page.It needs as to say the kind of file. A preload you don't use wastes data.
modulepreloadA JavaScript module, fetched early. The browser may fetch the modules it imports too.This page.Use it for important modules the browser would find late.
prefetchA file for a page the user may visit next, at low priority.The next page.It shouldn't slow down the files this page needs for its LCP.
Three hints, three different jobsHTML
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin><link rel="modulepreload" href="/assets/product-route.js"><link rel="prefetch" href="/assets/settings-route.js" as="script">

Line 1 preloads a font this page needs soon; as="font" says what kind of file it is. Line 2 fetches the product route's module early, and the browser may fetch the modules it imports too. Line 3 fetches the settings route's script at low priority, in case the user goes there next.

Lazy loading works for images too. loading="lazy" tells the browser to wait before it downloads an image or an iframe, until the user scrolls near it. Use it for things below the fold: the part of the page you can't see without scrolling.

fetchpriority tells the browser how important a file is. Use high for the big main image at the top of the page, called the hero image. Use low for a product picture far down the page.

A lazy image and an important imageHTML
<img  src="/products/chair.webp"  alt="Oak chair"  width="640"  height="480"  loading="lazy"  decoding="async"  fetchpriority="low"> <img  src="/hero.webp"  alt="New dashboard preview"  width="1280"  height="720"  fetchpriority="high">

Lines 1 to 9 are a chair photo far down the page. Line 6 makes it lazy. Line 7 lets the browser decode the image without making other content wait. Line 8 marks it low priority. Lines 11 to 17 are the hero image, and line 16 marks it high priority. Both images set width and height, so the browser can save space for them before they arrive.

Lazy loading can also make things slower

Lazy loading a slideshow far down the page is a win. But lazy loading the only code that draws the hero image or the first button makes the page slower to appear and slower to respond. Split code by what the user does first, not by guesswork.

async, defer, and module scripts

HTML RULES

The browser reads a page's HTML from top to bottom. The part that does this is the HTML parser. When the parser meets a <script> tag, the tag's attributes decide three things:

  • Does the parser stop and wait for the script?
  • Do the scripts run in the order they appear in the page?
  • Does DOMContentLoaded wait for the script? This event fires once the parser has read all the HTML and the deferred scripts have run.

A classic script is a normal script, without type="module". MDN sums up the rules:

  • An async classic script downloads while the parser keeps reading. It runs as soon as it arrives.
  • A defer script downloads while the parser keeps reading. It runs after the parser finishes, and before DOMContentLoaded.
  • A module script, type="module", acts like defer by default.
  • A module script can also be async.

These rules come from the HTML specification; MDN explains them in easier words. The simulator below follows only these loading rules. It doesn't copy everything a real browser does, such as network priorities.

The table compares five kinds of script tag. Read each row as: "this script runs at this time, in this order, and DOMContentLoaded does or doesn't wait for it."

Five kinds of script tag compared
Which script tag?When does it run?Does it keep page order?Does DOMContentLoaded wait?What is it good for?
Classic <script src>Right away. The parser stops and waits for it to download and run.Yes, page order.Yes. The parser can't finish until this script has run.A tiny script the page needs first, or old code you can't change.
async classicIt downloads while the parser keeps reading, then runs as soon as it arrives.No.No, it doesn't wait for this script.Scripts that work alone, like analytics, ads, or widgets.
defer classicIt downloads while the parser keeps reading, then runs after the parser finishes.Yes, page order.Yes, it waits for this script.App code that needs the whole page to be read first.
type="module"Like defer: after the parser finishes. The modules it imports download too.Yes, page order for the main module scripts in this simulator.Yes, it waits for the module and the modules it imports.The main script of a modern app.
async type="module"As soon as it and its imports have arrived.No.No, it doesn't wait for this script.A module widget that works alone.
Five script tags, five ways to loadHTML
<script src="legacy.js"></script><script src="analytics.js" async></script><script src="app.js" defer></script><script type="module" src="routes.js"></script><script type="module" async src="independent-widget.js"></script>

Line 1 is a classic script, so the parser stops for it. Line 2 is async. Line 3 is defer. Line 4 is a module, so it acts like defer. Line 5 is an async module.

CSS can hold up the page too. The browser usually won't draw (paint) the page until the stylesheets in the <head> have arrived and been read. So keep the CSS for the top of the page small, remove CSS you don't use, and don't make the hero wait for late styles.

The replay below runs a small simulator with four scripts: one of each kind. The parser alone would take 100 ms. Here is what the simulator finds:

The four scripts from the replay, in the order they run. While parsing: legacy.js, a classic script, at 60 ms, then analytics.js, async, at 80 ms. Parsing ends at 121 ms. After parsing: vendor.js, defer, at 121 ms, then app.js, a module, at 122 ms. DOMContentLoaded fires at 123 ms.while parsingafter parsinglegacy.jsanalytics.jsvendor.jsapp.js60 ms80 ms121 ms122 msclassicasyncdefermoduleparsing ends: 121 msDOMContentLoaded: 123 ms
legacy.js and analytics.js run while the HTML is still being read. vendor.js and app.js wait until reading ends, then run in page order before DOMContentLoaded.
Script loading: which script runs when, and when DOMContentLoaded fires
Step 0 of 8Ready
Your turn: follow the blue line

Step through a script loading timeline. This is a small simulator, not a real browser, but it follows the HTML spec and MDN rules for classic, async, defer, and module scripts.

Running in
  1. script
Next: line 40
function simulateScriptTimeline(scripts, parserDuration = 100) {  let parserDelay = 0;  const events = [];  const deferred = [];   for (const script of scripts) {    const discoveredAt = script.at + parserDelay;    const readyAt = discoveredAt + script.downloadMs;     if (script.kind !== "module" && !script.async && !script.defer) {      events.push({ time: readyAt, label: "execute " + script.name });      parserDelay += script.downloadMs + 1;      continue;    }     if (script.async) {      events.push({ time: readyAt, label: "execute " + script.name });    } else {      deferred.push({ ...script, discoveredAt });    }  }   const parserDoneAt = parserDuration + parserDelay;  let clock = parserDoneAt;   for (const script of deferred) {    const readyAt = script.discoveredAt + script.downloadMs;    const startAt = Math.max(clock, readyAt);    events.push({ time: startAt, label: "execute " + script.name });    clock = startAt + 1;  }   events.sort((a, b) => a.time - b.time || a.label.localeCompare(b.label));  return {    executionOrder: events.map((event) => event.label.replace("execute ", "")),    domContentLoadedAt: clock,  };}   { name: "analytics.js", at: 10, downloadMs: 70, async: true },  { name: "vendor.js", at: 20, downloadMs: 40, defer: true },  { name: "app.js", at: 30, downloadMs: 10, kind: "module" },  { name: "legacy.js", at: 40, downloadMs: 20 },]; const timeline = simulateScriptTimeline(scripts, 100);console.log(timeline.executionOrder.join(" -> "));console.log("DOMContentLoaded @" + timeline.domContentLoadedAt + "ms");
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Press Step to see each rule at work. legacy.js holds the parser up for 21 ms, so parsing ends at 121 ms instead of 100 ms.

Change one script attribute and watch the timeline
The script tags right nowHTML
<script src="dom-ready.js" defer></script><script src="feature.js" defer></script>
What the simulator founddefer
run orderdom-ready.js -> feature.js
DOMContentLoadedDOMContentLoaded @102ms
parsing ends100ms
Try it yourself
How feature.js loads

defer keeps page order. The script downloads while the parser reads, then runs before DOMContentLoaded. It is the usual choice for classic app scripts.

This playground uses the same simulator as the replay above. Change one attribute, then compare the run order and the DOMContentLoaded time.

In the playground, pick a different attribute for feature.js. Watch the run order and the time of DOMContentLoaded change.

Caching and code caching

REPEAT VISITS

A fast first visit still matters. But on the next visit, the browser shouldn't have to do everything again. A cache is a place that keeps copies so they can be used again. Each kind of cache saves a different kind of repeat work:

  • The browser's HTTP cache keeps downloaded files.
  • Content-hashed file names, like app-shell.8f3a1c.js, make it safe to keep files for a long time.
  • A CDN (content delivery network) keeps copies on servers close to users.
  • A service worker, a script that sits between the page and the network, can answer requests from its own cache.
  • The back/forward cache keeps a whole page ready for the Back and Forward buttons.
  • V8 code caching keeps the compiled form of a script.
Keep a file for a year, because its name changesHTTP
Cache-Control: public, max-age=31536000, immutableETag: "app-shell.8f3a1c" /assets/app-shell.8f3a1c.js  -> cache for a year/assets/app-shell.91bd22.js  -> new URL after content changes

Line 1 tells the browser it may keep this file for a year (31,536,000 seconds), and that the file will never change (immutable). That is safe only because the file name has a content hash: a short code made from the file's content, like 8f3a1c. Line 4 is today's file. Line 5 is the file after a code change: new content, so a new hash and a new URL. Without the hash, users could be stuck with old JavaScript.

Line 2 is an ETag: a label for this exact version of the file. When the browser asks "has this changed?", the server can answer 304 Not Modified with no file attached. That saves bytes, but it still costs a request. A CDN makes each request travel a shorter way, because its servers are close to users. The browser's own cache is better still: it can skip many repeat requests completely.

A service worker that answers scripts from its cache firstJavaScript
self.addEventListener("fetch", (event) => {  const request = event.request;   if (request.destination === "script") {    event.respondWith(      caches.open("static-v4").then(async (cache) => {        const cached = await cache.match(request);        if (cached) return cached;        const response = await fetch(request);        cache.put(request, response.clone());        return response;      }),    );  }});

Line 1 listens for every request the page makes. Line 4 handles scripts only. Line 5 says the worker will answer this request itself. Lines 7 and 8 look for a saved copy and return it if there is one. If not, line 9 downloads the script, line 10 saves a copy, and line 11 returns it.

Service workers can make an app fast and even let it work offline. But they are a cache you program yourself, so you must also clean it up. The service workers lesson covers how a worker starts, which pages it controls, how to clean up, and offline fallbacks. The Cache API lesson explains that a named cache stores pairs of a Request and its Response. Unlike the HTTP cache, it doesn't throw old files away by itself.

V8 code caching is not HTTP caching

V8 is the JavaScript engine in Chrome. After it compiles a script, it can save the result, called bytecode. If you come back soon and the page loads the exact same script, V8 can reuse that bytecode and skip some parse and compile work. This often works together with the HTTP cache.

If even one byte of the script changes, the saved bytecode can't be used. Code caching cuts parse and compile time. It doesn't remove the download, the memory, or the time to execute new code.

The back/forward cache, or bfcache, keeps a whole snapshot of a page. When the user presses Back, the page comes back at once. Some code stops the browser from using it, like the first half of this example.

Send analytics without breaking the back/forward cacheJavaScript
// Avoid this pattern for analytics or cleanup.window.addEventListener("unload", () => {  navigator.sendBeacon("/analytics", "left page");}); // Prefer signals that keep the back/forward cache available.document.addEventListener("visibilitychange", () => {  if (document.visibilityState === "hidden") {    navigator.sendBeacon("/analytics", "hidden");  }});

Lines 2 to 4 listen for unload, the event that fires when the page is closed. A page-wide unload listener often stops the bfcache from working. Lines 7 to 11 do the same job with visibilitychange: when the page becomes hidden (line 8), line 9 sends the data. pagehide also works. Read the page lifecycle lesson before you add code that runs when the user leaves.

Core Web Vitals

FIELD METRICS

Core Web Vitals are three numbers that measure how a page feels to real users:

  • LCP (Largest Contentful Paint): how long until the biggest thing on the first screen appears, often the hero image or the main heading.
  • INP (Interaction to Next Paint): how long the page takes to show a response after a click, tap, or key press.
  • CLS (Cumulative Layout Shift): how much the page jumps around while it loads.

They are field data: numbers measured on real users' devices, not in a test on your own computer. web.dev's Web Vitals guide, last updated on October 31, 2024, says a page is good when:

  • LCP is 2.5 seconds or less,
  • INP is 200 milliseconds or less,
  • CLS is 0.1 or less.

They are checked at the 75th percentile: at least 75 out of every 100 page loads must meet the number. Mobile and desktop are checked separately.

The table shows how JavaScript can hurt each one. Read each row as: "this number is good at this level, and JavaScript can hurt it like this."

How JavaScript can hurt each Core Web Vital
Short nameFull nameWhen is it good?How can JavaScript hurt it?
LCPLargest Contentful PaintGood at 2.5 seconds or less.JavaScript draws the hero, the browser finds the hero image late, or the image is fetched at low priority.
INPInteraction to Next PaintGood at 200 milliseconds or less.Hydration, long start-up tasks, and big event handlers keep the main thread busy.
CLSCumulative Layout ShiftGood at 0.1 or less.Banners added late, images without a size, and content drawn only in the browser push the page around.

Many frameworks send HTML from the server, then run JavaScript in the browser to attach click handlers to it. That step is called hydration. With that word, here is how JavaScript hurts each number:

  • LCP gets slower when JavaScript has to draw the hero, when the browser finds the hero image only after hydration, or when important CSS arrives late.
  • INP gets slower when long hydration, big event handlers, or start-up work keep the main thread busy.
  • CLS gets worse when JavaScript adds banners, ads, or other content after the page is laid out, without saving space for it.

PerformanceObserver is a browser tool that tells your code about performance events, such as the largest paint so far or a layout shift.

Watch for Web Vitals entries with PerformanceObserverPop out in the code editor (opens in a new tab)JavaScript
const supported = "PerformanceObserver" in globalThis  ? PerformanceObserver.supportedEntryTypes || []  : [];const watched = ["largest-contentful-paint", "layout-shift", "event"]  .filter((type) => supported.includes(type)); console.log("Can watch: " + (watched.join(", ") || "none in this browser")); if (watched.length > 0) {  const observer = new PerformanceObserver((list) => {    for (const entry of list.getEntries()) {      console.log(entry.entryType + " @" + Math.round(entry.startTime));    }  });  observer.observe({ type: watched[0], buffered: true });  observer.disconnect();}

Lines 1 to 3 ask which kinds of entry this browser supports. Lines 4 and 5 keep only the three this lesson cares about. Line 7 prints the ones this browser can watch. Line 10 makes an observer that prints each entry's type and time. Line 15 starts watching the first type; buffered: true also asks for entries from before. Line 16 stops watching straight away, because this is a quick demo. A real app keeps the observer running.

In real apps, many teams use the web-vitals library instead. It handles the differences between browsers and reports the final value of each number. It isn't installed on this site, so the next block can't run here.

How the web-vitals library is usedJavaScript
import { onCLS, onINP, onLCP } from "web-vitals"; onLCP((metric) => sendToAnalytics("LCP", metric.value));onINP((metric) => sendToAnalytics("INP", metric.value));onCLS((metric) => sendToAnalytics("CLS", metric.value));

Line 1 imports three functions. Lines 3 to 5 each give the library a function to call when a value is ready. sendToAnalytics stands for your own code that sends the number to your server.

Which Web Vital does this improve?
  • Put the hero image in the HTML the server sends, and give it fetchpriority="high".
  • Split out an editor that people rarely use, so hydration runs less JavaScript on the product page.
  • Set an image's width and height before the lazy image loads.
  • Send app.8f3a1c.js with Cache-Control: immutable.
  • Remove a page-wide unload listener so the page can use the bfcache.
  • Save space for a cookie banner that is added after hydration.
  • Break a 700 ms start-up task into smaller pieces before the buttons work.
  • Preload the CSS that the hero at the top of the page needs.
Try it yourself
0 of 8 correct

Put each change under the number it helps most. Some changes are useful but don't fix any one Core Web Vital: put those in the last group.

Choose a category for every card. You can change an answer at any time; Reset clears them all.
Lighthouse tests one machine, not all your users

Lighthouse is a tool in Chrome that loads your page in a controlled test. Its results are lab data. Lab data is great for repeating a test and finding the cause of a slow load.

Field data comes from real users. It comes from your own monitoring, called RUM (real user monitoring), or from CrUX, Chrome's public report of real-user numbers. Use both: lab data to explain a slow spot, and field data to choose what to fix and to check that the fix worked.

A plan for real projects

MEASURE + ENFORCE

Start every speed change by measuring. The measuring performance lesson shows how to time code, record traces in DevTools, and collect numbers from real users. The main thread lesson shows how long tasks make clicks feel slow. With those numbers, you can decide what to remove, split, preload, or cache. Here is a plan, step by step:

  1. Open a bundle analyzer. List the chunks the slow route loads at the start, the ones it loads later, and the ones it shares with other routes.
  2. Record a trace in the Performance panel with a slowed-down mobile setting. Check the time spent on parse, compile, execute, and hydration.
  3. Set budgets: the compressed JavaScript at the start, the biggest lazy chunk, all of the route's JavaScript, and the length of long tasks.
  4. Move code the user doesn't need at first to another route, or load it with import() when the user asks for it.
  5. Give files that never change a content hash and a long cache time. Use a service worker only if you can also clean up its cache.
  6. After the change goes live, check LCP, INP, and CLS in field data.
A question to ask in code review

Is this code needed for the first screen? If a library is needed only after the user opens an editor, it shouldn't be in the product page's first chunk. But if it is needed to draw the first screen, lazy loading it makes the download smaller and the page slower to appear (a worse LCP).

Common mix-ups

  • "Only the gzip size matters." No. The download size matters, but parse, compile, memory, and execute all work on the full, uncompressed text.
  • "Lazy loading always makes a page faster." No. It helps only when the code or image you delay isn't needed for what the user sees or does first.
  • "async is better than defer." No. async is for scripts that don't depend on anything else. App code that needs page order usually wants defer or a module.
  • "A green Lighthouse score means real users are fine." No. Lab tests help, but field data also includes slow phones, background tabs, and real networks.
  • "Caching fixes too much JavaScript." No. Caching helps repeat visits. First visits, changed code, memory, and execute time still cost the same.

The table separates four terms that people often mix up. Read each row as: "this term means this, so use it for this."

Four terms that are easy to mix up
Which term?What does it mean?What is it useful for?
Compressed sizeThe size that travels over the network.Download budgets and what the CDN costs.
Uncompressed sizeThe size of the text the engine parses after decompressing.Guessing parse and compile time, and checking new libraries.
Lazy loadingDoing some work later instead of at the start.Only work that is truly not needed for the first screen or the first click.
CachingKeeping copies so a repeat visit can skip a download or a compile.Repeat visits. It doesn't make the first visit free, and it can keep old code by mistake.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upTotal the compressed chunks

The array holds the compressed sizes of three chunks, in KB. Type the total the program prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const chunks = [82, 63, 118];
console.log(chunks.reduce((total, kb) => total + kb, 0));

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

    Exercise 2 · PracticePredict the order deferred scripts run in

    Two deferred scripts finished downloading in the order shown. What run order does the program print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const downloaded = ["app.js", "vendor.js"];
    const documentOrder = { "vendor.js": 1, "app.js": 2 };
    downloaded.sort((left, right) => documentOrder[left] - documentOrder[right]);
    console.log(downloaded.join(" -> "));

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

      Exercise 3 · PracticeChoose the attribute that keeps order

      Which attribute keeps classic scripts in page order without stopping the parser?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const attribute = "defer";
      console.log(attribute);

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

        Exercise 4 · PracticeName the Web Vital

        The program checks three changes to the hero image. Type the Web Vital they help most.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const hero = { renderedByServer: true, hasWidthAndHeight: true, fetchPriority: "high" };
        console.log(hero.renderedByServer && hero.hasWidthAndHeight && hero.fetchPriority === "high" ? "LCP" : "INP");

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

          Exercise 5 · ChallengeExplain the cache header choice

          A file name has a content hash in it. Which cache setting does that make safe?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const file = "/assets/app.8f3a1c.js";
          console.log(file.includes("8f3a1c") ? "immutable" : "revalidate");

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

            Check your understanding

            8 QUESTIONS
            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Why does JavaScript usually cost more, byte for byte, than an image?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this program print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const chunks = [82, 63, 118];
              console.log(chunks.reduce((total, kb) => total + kb, 0));

              Choose an answer to see the explanation.

            3. Question 3 of 8Which script keeps page order, and runs after the parser finishes but before DOMContentLoaded?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does this deferred-script example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const downloaded = ["app.js", "vendor.js"];
              const documentOrder = { "vendor.js": 1, "app.js": 2 };
              downloaded.sort((left, right) => documentOrder[left] - documentOrder[right]);
              console.log(downloaded.join(" -> "));

              Choose an answer to see the explanation.

            5. Question 5 of 8The user may visit a route next, but the current page doesn't need it. Which hint fits?

              Choose an answer to see the explanation.

            6. Question 6 of 8Which sentence about repeat visits is true?

              Choose an answer to see the explanation.

            7. Question 7 of 8What does the hero image example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const hero = { renderedByServer: true, hasWidthAndHeight: true, fetchPriority: "high" };
              console.log(hero.renderedByServer && hero.hasWidthAndHeight && hero.fetchPriority === "high" ? "LCP" : "INP");

              Choose an answer to see the explanation.

            8. Question 8 of 8How should you use Lighthouse and field data together?

              Choose an answer to see the explanation.

            Key takeaways

            • A JavaScript file costs five jobs: download, decompress, parse, compile, and execute.
            • Budgets should track the code at the start, the biggest lazy chunk, the total, and the CPU time.
            • Split code by route, and use import() on a click, only for code the user doesn't need first.
            • defer and module scripts keep page order; async is for scripts that don't depend on anything.
            • The HTTP cache, service workers, V8 code caching, and the bfcache each save different repeat work.
            • Check LCP, INP, and CLS with field data, at the 75th percentile.

            Remember the one-liner.
            Send the smallest route that works, load extra code when the user needs it, and check the result on real users, not only on your laptop.

            Next in the Performance module: Finding memory leaks. There you will use heap snapshots, a list of everything in memory at one moment, to find what keeps old objects alive.

            CompleteFrontend Clear concepts. Working examples.