Loading performance
Learn how to reduce JavaScript bytes, split code, choose async or defer, cache smarter, and protect Core Web Vitals on real devices.
- 01Set 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.
- 02Choose how and when code loadsPick between one chunk per route,
import()on a click, resource hints, lazy images,async,defer, and module scripts. - 03Protect 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:
- Don't send code the page doesn't need.
- Send code later, at the moment the user needs it.
- Let the browser keep code and reuse it on the next visit.
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.
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 + CPUA 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:
- Download the compressed file.
- Decompress it back into the full source text.
- Parse it: read the text and work out how the code is built.
- Compile it: turn it into instructions the computer can run.
- Execute it: run the code.
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."
| Which job? | What does it cost? |
|---|---|
| Download | The compressed file travels over the network. Brotli or gzip makes it smaller, but the whole file must arrive before it can run. |
| Decompress | The file grows back into the full source text. So track two sizes: the compressed size and the uncompressed size. |
| Parse | The engine reads the text and works out how the code is built. More text means more reading before the code can run. |
| Compile | Engines such as V8 turn the code into instructions the computer can run. On a repeat visit, they may reuse saved instructions. |
| Execute | Your 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 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.
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.
GET /assets/app.js
Accept-Encoding: br, gzip
HTTP/2 200
Content-Encoding: br
Vary: Accept-EncodingLine by line:
- Line 1: the browser asks for
app.js. - Line 2:
Accept-Encodinglists 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: brsays the server picked Brotli. - Line 6:
Vary: Accept-Encodingis 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:
- List every chunk: the ones the page loads at the start, and the ones it loads later (lazy chunks).
- Mark whether each chunk is needed for the current page.
- Set a size limit, called a budget.
- 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 THROUGHA 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.
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.
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.
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.
script
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");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 + INTERACTIONSCode 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?
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.
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."
| Which hint? | What does it ask for? | Which page does it help? | What should you watch out for? |
|---|---|---|---|
preload | A file this page needs soon. | This page. | It needs as to say the kind of file. A preload you don't use wastes data. |
modulepreload | A 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. |
prefetch | A 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. |
<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.
<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 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 RULESThe 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
DOMContentLoadedwait 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
asyncclassic script downloads while the parser keeps reading. It runs as soon as it arrives. - A
deferscript downloads while the parser keeps reading. It runs after the parser finishes, and beforeDOMContentLoaded. - A module script,
type="module", acts likedeferby 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."
| 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 classic | It 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 classic | It 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. |
<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:
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.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.
script
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");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.
<script src="dom-ready.js" defer></script><script src="feature.js" defer></script>dom-ready.js -> feature.jsDOMContentLoaded @102ms100msdefer keeps page order. The script downloads while the parser reads, then runs before DOMContentLoaded. It is the usual choice for classic app scripts.
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 VISITSA 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.
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 changesLine 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.
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 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.
// 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 METRICSCore 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."
| Short name | Full name | When is it good? | How can JavaScript hurt it? |
|---|---|---|---|
| LCP | Largest Contentful Paint | Good 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. |
| INP | Interaction to Next Paint | Good at 200 milliseconds or less. | Hydration, long start-up tasks, and big event handlers keep the main thread busy. |
| CLS | Cumulative Layout Shift | Good 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.
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.
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.
- 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.jswithCache-Control: immutable. - Remove a page-wide
unloadlistener 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.
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.
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 + ENFORCEStart 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:
- 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.
- Record a trace in the Performance panel with a slowed-down mobile setting. Check the time spent on parse, compile, execute, and hydration.
- Set budgets: the compressed JavaScript at the start, the biggest lazy chunk, all of the route's JavaScript, and the length of long tasks.
- Move code the user doesn't need at first to another route, or load it with
import()when the user asks for it. - 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.
- After the change goes live, check LCP, INP, and CLS in field data.
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.
asyncis for scripts that don't depend on anything else. App code that needs page order usually wantsdeferor 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."
| Which term? | What does it mean? | What is it useful for? |
|---|---|---|
| Compressed size | The size that travels over the network. | Download budgets and what the CDN costs. |
| Uncompressed size | The size of the text the engine parses after decompressing. | Guessing parse and compile time, and checking new libraries. |
| Lazy loading | Doing some work later instead of at the start. | Only work that is truly not needed for the first screen or the first click. |
| Caching | Keeping 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 EXERCISESThe array holds the compressed sizes of three chunks, in KB. Type the total the program prints.
const chunks = [82, 63, 118];
console.log(chunks.reduce((total, kb) => total + kb, 0));The program prints 263. That is 3 KB over the 260 KB total budget from the budget checker.
Two deferred scripts finished downloading in the order shown. What run order does the program print?
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(" -> "));The array starts with app.js, but vendor.js comes first in the page (number 1). So the program prints vendor.js -> app.js.
Which attribute keeps classic scripts in page order without stopping the parser?
const attribute = "defer";
console.log(attribute);Use defer for classic app scripts that should download while the parser keeps reading, then run in page order after it finishes.
The program checks three changes to the hero image. Type the Web Vital they help most.
const hero = { renderedByServer: true, hasWidthAndHeight: true, fetchPriority: "high" };
console.log(hero.renderedByServer && hero.hasWidthAndHeight && hero.fetchPriority === "high" ? "LCP" : "INP");It mostly helps LCP. The server sends the hero in the HTML, so the browser finds it early and fetches it with high priority.
A file name has a content hash in it. Which cache setting does that make safe?
const file = "/assets/app.8f3a1c.js";
console.log(file.includes("8f3a1c") ? "immutable" : "revalidate");Cache-Control: public, max-age=31536000, immutableA content hash makes a year-long immutable cache safe, because a changed file gets a new URL.
Check your understanding
8 QUESTIONSQuestion 1 of 8Why does JavaScript usually cost more, byte for byte, than an image?
Choose an answer to see the explanation.
Question 2 of 8What does this program print?
Read the code, then predictconst chunks = [82, 63, 118]; console.log(chunks.reduce((total, kb) => total + kb, 0));Choose an answer to see the explanation.
Question 3 of 8Which script keeps page order, and runs after the parser finishes but before DOMContentLoaded?
Choose an answer to see the explanation.
Question 4 of 8What does this deferred-script example print?
Read the code, then predictconst 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.
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.
Question 6 of 8Which sentence about repeat visits is true?
Choose an answer to see the explanation.
Question 7 of 8What does the hero image example print?
Read the code, then predictconst 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.
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. deferand module scripts keep page order;asyncis 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.