Loading performance
Learn how to reduce JavaScript bytes, split code, choose async or defer, cache smarter, and protect Core Web Vitals on real devices.
- 01Budget JavaScript honestlySeparate compressed transfer bytes from parse, compile, and execute cost, then enforce route and chunk budgets.
- 02Load code deliberatelyChoose route splitting, interaction-time import(), resource hints, lazy media, async, defer, or modules for the job.
- 03Protect user metricsConnect loading choices to LCP, INP, CLS, repeat-visit caching, bfcache, Lighthouse, and field data.
Ship less, start sooner
Loading performance is the discipline of making the browser do less work before the page is useful. The fastest JavaScript is often the code you do not send, followed by code you send later, then code the browser can reuse on the next visit.
Loading performance means controlling how much JavaScript, CSS, media, and data arrive on the critical path; when each resource is discovered; and how much main thread work runs before the first useful paint and interaction.
You do not carry winter boots, a toolbox, and a spare monitor onto every flight. You pack what the first day needs, send bulky items separately, and reuse what is already waiting at the destination. Loading performance asks the same questions about code.
- In real life: Carry-on bag
- In JavaScript: Initial JavaScript for the first route
- In real life: Checked bag
- In JavaScript: Lazy chunks loaded after navigation or interaction
- In real life: Packing list
- In JavaScript: Bundle analyzer report and budget
- In real life: Hotel storage
- In JavaScript: HTTP cache, service worker cache, and code cache
Where the analogy stops: A suitcase does not parse, compile, or hydrate. JavaScript does, so a small-looking item can still be expensive when it runs on a slow phone.
This lesson builds directly on bundlers, dynamic import, page lifecycle, Cache API and file storage, service workers, and engines and runtimes. It also links forward to measuring performance and keeping the main thread responsive.
The cost of JavaScript
BYTES + CPUJavaScript has a longer bill than many resources. A compressed file must download, decompress into source text, parse, compile, and execute. Images can be huge, but byte-for-byte a script usually asks more from the CPU and the main thread before the page responds.
| Stage | What the browser pays |
|---|---|
| Download | The network transfers compressed bytes. Brotli or gzip helps, but the browser must still receive the file before it can run. |
| Decompress | The compressed response expands into larger source text. Budgets should track both compressed transfer and uncompressed JavaScript. |
| Parse | The engine turns source text into syntax structures. More source means more parser work before execution can finish. |
| Compile | Engines such as V8 compile JavaScript to bytecode or optimized machine code. Repeat visits may reuse cached bytecode. |
| Execute | Your code runs on the main thread unless you move work to a worker. Hydration and startup handlers can block input. |
The 2024 Web Almanac JavaScript chapter, published on March 3, 2025 from the June 2024 HTTP Archive crawl, reports median JavaScript payloads of 558 KB on mobile and 613 KB on desktop. The same chapter says mobile JavaScript grew 14% year over year. web.dev's JavaScript startup guidance reminds us that average mobile phones can have slow CPUs, limited cache, and memory pressure, so a fast office laptop is not representative.
Track compressed kilobytes because the network sees them. Track uncompressed kilobytes because the engine parses those. Then inspect parse, compile, and execute time in the Performance panel instead of guessing from bytes alone.
Bundle analysis turns this into a team habit: list every entry and lazy chunk, label whether it is needed for the current route, set a budget, and fail pull requests when the budget grows without a conscious trade-off.
A real budget checker
STEP THROUGHThe next function is intentionally small, but it is real. It accepts a list of chunks and a budget, totals initial bytes, lazy bytes, total bytes, and estimated parse/compile time, then reports exactly which limit failed.
Step through a real JavaScript budget checker. It totals compressed transfer bytes, estimates parse/compile time from uncompressed bytes, and reports each budget failure.
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");In production you would feed this kind of function from a bundler manifest, a stats file, or a CI artifact. This site is a Next.js static export, so each route has its own chunks, but this lesson does not quote local build numbers because a fresh production build was intentionally not run while the shared dev server is active.
Code splitting and lazy loading
ROUTES + INTERACTIONSRoute-level splitting keeps the first page from downloading code for every other page. App frameworks such as Next.js make route chunks by default, and bundlers create more split points when they see import(). The key question is whether the user needs the code for the first useful task.
const button = document.querySelector("#load-chart");const output = document.querySelector("#chart-output"); button.addEventListener("click", async () => { button.disabled = true; output.textContent = "Loading chart code..."; const chart = await import("./chart.js"); chart.renderChart(output);});This is different from hiding work behind a timeout. The network request for chart.js does not start until the click handler reaches import(). The dynamic import lesson covers the promise and module namespace behavior in detail.
| Hint | Meaning | Scope | Watch out |
|---|---|---|---|
preload | Fetch a resource needed by the current page soon | Current navigation | Must include as, and wrong preloads waste bandwidth |
modulepreload | Fetch a module and its dependency graph earlier | Current navigation | Use for important modules discovered late |
prefetch | Fetch likely future route resources at low priority | Next navigation | Should not compete with current LCP resources |
<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">Lazy loading is not only for JavaScript. Native loading="lazy" keeps below-the-fold images and iframes off the initial path. fetchpriority lets you tell the browser when a discovered resource is unusually important or unimportant, such as a likely LCP hero image versus a far-down product thumbnail.
<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">Lazy loading a below-the-fold carousel is a win. Lazy loading the only code that renders the hero or the first button can delay LCP or INP. Split by user journey, not by wishful thinking.
async, defer, and module scripts
HTML RULESScript attributes decide whether HTML parsing stops, whether document order is preserved, and whether DOMContentLoaded waits. MDN summarizes the rules: async classic scripts fetch during parsing and evaluate as soon as available; defer scripts run after parsing and before DOMContentLoaded; module scripts defer by default; async modules are allowed.
The HTML specification is the source for those script-processing queues; MDN is the readable reference used here for the day-to-day rules. The simulator below encodes only those loading rules, not every network-priority or engine detail inside a browser.
| Script | When it runs | Order | DOMContentLoaded | Use it for |
|---|---|---|---|---|
Classic <script src> | Blocks parsing when discovered | Document order | DOMContentLoaded waits because parsing cannot finish until it runs | Small critical inline bootstrap or legacy dependency you cannot defer |
async classic | Downloads during parsing, runs as soon as ready | No | Does not delay DOMContentLoaded | Independent analytics, ads, or widgets |
defer classic | Downloads during parsing, runs after parsing | Yes | DOMContentLoaded waits for it | App code that needs the parsed DOM |
type="module" | Deferred by default; dependency graph fetches as modules | Document order for entry modules in this model | DOMContentLoaded waits for the entry module graph | Modern app entry points |
async type="module" | Module graph runs as soon as ready | No | Does not wait for document order | Independent module widget |
<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>CSS can be render-blocking too. A stylesheet in the document head can delay first paint until it arrives and is parsed. Splitting critical CSS, avoiding unused CSS, and not hiding the hero behind late styles matter alongside JavaScript loading.
Step through a script-loading timeline. This is a small deterministic model, not a browser engine, but it encodes the rules from the HTML spec and MDN 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");<script src="dom-ready.js" defer></script><script src="feature.js" defer></script>dom-ready.js -> feature.jsDOMContentLoaded @102ms100msDefer keeps order, downloads during parsing, and runs before DOMContentLoaded. It is the usual choice for classic app scripts.
Caching and code caching
REPEAT VISITSA good first load still matters, but repeat visits should not pay the same network bill. HTTP caching, content-hashed filenames, CDN edge caching, service worker strategies, bfcache, and V8 code caching all reduce different kinds of repeated work.
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 changesCache-Control: max-age=31536000, immutable is safe only when a changed file gets a new URL. Without a content hash, users can stay stuck on old JavaScript. ETag and 304 Not Modified save response bytes after revalidation, but they still require a request. A CDN edge cache moves reusable responses closer to users, while browser caching removes 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; }), ); }});Service workers can make an app resilient and fast, but they are a programmable cache layer. The service workers lesson covers lifecycle, scope, cleanup, and offline fallbacks. The Cache API lesson explains why a named Cache stores Request to Response pairs and does not obey HTTP expiration automatically.
V8 can serialize compiled bytecode for identical JavaScript source and reuse it on a hot repeat visit, often alongside the HTTP cache. If the source changes byte-for-byte, the code cache is invalid. It can reduce parse/compile cost; it does not remove download, memory, or execution work for new code.
// 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"); }});The back/forward cache stores a whole page snapshot for instant history navigation. Global unload handlers are a classic bfcache breaker; prefer pagehide or visibilitychange for analytics and quick saves. Review page lifecycle before adding leave handlers.
Core Web Vitals
FIELD METRICSCore Web Vitals are field metrics, not just lab scores. As of web.dev's Web Vitals guidance last updated October 31, 2024, a good page experience means LCP at or below 2.5 seconds, INP at or below 200 milliseconds, and CLS at or below 0.1, evaluated at the 75th percentile of page loads separately for mobile and desktop.
| Metric | Name | Good threshold | How JavaScript can hurt it |
|---|---|---|---|
| LCP | Largest Contentful Paint | Good at 2.5 seconds or less | Client-rendered hero markup, late hero image discovery, or low-priority image fetches delay it |
| INP | Interaction to Next Paint | Good at 200 milliseconds or less | Hydration, long startup tasks, and huge event handlers keep the main thread busy |
| CLS | Cumulative Layout Shift | Good at 0.1 or less | Late-injected banners, images without dimensions, and client-only content can move the page |
JavaScript can delay LCP by client-rendering the hero, by discovering the hero image only after hydration, or by loading critical CSS late. It can delay INP with long hydration tasks, big event handlers, and startup work. It can cause CLS by injecting banners, ads, or client content after layout without reserving space.
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();}In production, many teams use the web-vitals library because it handles browser differences and reports final metric values. It is not installed in this lesson, so the next block is a non-runnable usage shape.
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));- Render the hero image in the initial HTML and give it
fetchpriority="high". - Split a rarely used editor so hydration has less JavaScript on the product page.
- Set image width and height before lazy images load.
- Serve
app.8f3a1c.jswithCache-Control: immutable. - Remove a global
unloadhandler so pages can use bfcache. - Reserve space for a consent banner inserted after hydration.
- Break a 700 ms startup task into chunks before enabling buttons.
- Preload the CSS that reveals above-the-fold hero content.
Sort each optimization by the metric it most directly helps. Some changes are valuable without mapping to one Core Web Vital.
Lighthouse is excellent for a reproducible trace and local debugging. Field data from RUM or CrUX tells you what real devices, networks, and pages experienced. Use both: lab to explain a bottleneck, field data to prioritize and confirm it.
Production workflow
MEASURE + ENFORCEStart every performance change by measuring. The measuring performance lesson shows marks, measures, DevTools traces, and RUM. The main thread lesson shows how long tasks hurt interaction. Loading performance uses those signals to decide what to remove, split, preload, or cache.
- Open a bundle analyzer and list initial, async, and shared chunks for the slow route.
- Record a Performance panel trace on a throttled mobile profile and inspect parse, compile, execute, and hydration time.
- Set budgets for initial compressed JS, largest async chunk, total route JS, and long tasks.
- Move optional work behind route boundaries or interaction-time
import(). - Use HTTP caching and content hashes for immutable assets; use service workers only when you can own cleanup.
- Check LCP, INP, and CLS in field data after the change ships.
If a dependency is needed only after a user opens an editor, it should not be in the product page's initial route chunk. If it is needed to render the first screen, lazy loading it may make the page look faster in bytes while making LCP worse.
Common misconceptions
- “Gzip size is the only size that matters.” Transfer size matters, but parse, compile, memory, and execution follow the uncompressed source.
- “Lazy loading is automatically faster.” It is faster only when the delayed resource is not needed for the current view or task.
- “async is better than defer.” Async is for independent scripts. App code that depends on DOM order usually wants defer or modules.
- “A green Lighthouse run means real users are fine.” Lab scores are useful, but field data captures slow devices, background tabs, and real network conditions.
- “Caching fixes too much JavaScript.” Caching helps repeat visits; first visits, code changes, memory, and execution still pay.
| Term | Meaning | Use it for |
|---|---|---|
| Compressed bytes | What the network transfers | Use for transfer budgets and CDN cost |
| Uncompressed bytes | What the engine parses after decompression | Use for parse/compile risk and dependency reviews |
| Lazy loading | Moves work later | Helps only when the delayed work is truly not needed for the first task |
| Caching | Avoids repeat transfer or compilation | Does not make the first visit free and can hide stale-code bugs |
Practice exercises
5 EXERCISESType the total compressed JavaScript printed by the small reducer.
const chunks = [82, 63, 118];
console.log(chunks.reduce((total, kb) => total + kb, 0));The program prints 263, so the route is 3 KB over the 260 KB total budget in the lesson scenario.
Type the execution order from the simulator, arrows included.
const order = ["legacy.js", "analytics.js", "vendor.js", "app.js"];
console.log(order.join(" -> "));The order is legacy.js -> analytics.js -> vendor.js -> app.js.
Which attribute keeps external classic scripts ordered without blocking the parser?
const attribute = "defer";
console.log(attribute);Use defer for external classic app scripts that should download during parsing and run in order after parsing.
Type the metric most directly improved by the hero optimization.
const hero = { renderedByServer: true, hasWidthAndHeight: true, fetchPriority: "high" };
console.log(hero.renderedByServer && hero.hasWidthAndHeight && hero.fetchPriority === "high" ? "LCP" : "INP");This mostly helps LCP because it makes the likely largest element discoverable and prioritized earlier.
What cache strategy does a content-hashed filename 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 is JavaScript usually more expensive per byte than an image?
Choose an answer to see the explanation.
Question 2 of 8What does the budget checker print first?
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 loading pattern preserves order and runs after parsing but before DOMContentLoaded?
Choose an answer to see the explanation.
Question 4 of 8What does this script order model print?
Read the code, then predictconst order = ["legacy.js", "analytics.js", "vendor.js", "app.js"]; console.log(order.join(" -> "));Choose an answer to see the explanation.
Question 5 of 8What should you use for a route the user might visit next, but not for the current LCP path?
Choose an answer to see the explanation.
Question 6 of 8Which statement about repeat visits is accurate?
Choose an answer to see the explanation.
Question 7 of 8What does the hero optimization 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 Lighthouse and field data be used together?
Choose an answer to see the explanation.
Key takeaways
- JavaScript costs download, decompression, parse, compile, and execution.
- Budgets should track initial chunks, lazy chunks, total transfer, and CPU risk.
- Use route splitting and interaction-time
import()only when the code is not needed for the first task. deferand module scripts preserve ordered startup;asyncis for independent work.- HTTP caching, service workers, V8 code caching, and bfcache solve different repeat-visit problems.
- LCP, INP, and CLS must be confirmed with field data at the 75th percentile.
Remember the one-liner.
Ship the smallest useful route, load optional code at the moment of need, and verify the result on real users rather than your laptop.
Next in the Performance module: Finding memory leaks, where you will use heap snapshots to find what keeps objects alive.