The cost of JavaScript
A kilobyte of JavaScript costs far more than a kilobyte of image: download, parse, compile, and run. Measured in Chrome: a script against a picture of the same size, three bundle sizes at three CPU speeds, and code caching across three visits.
- 01Follow a script's bytesSay what the browser does with a script after it arrives, and which of that work lands on the main thread.
- 02Keep compiled code cachedExplain V8's cold, warm and hot runs, and what makes a visitor start again from cold.
- 03Estimate the cost on a phoneWork out main-thread time for each kilobyte, see how a slower CPU makes it grow, and load less at start-up.
A kilobyte isn't a kilobyte
Asha opens the Leaf and Kettle page on her phone, on the bus. Say the shop adds two things to its page. One is a photo of the counter. The other is a small app for ordering tea. The photo is about 300 KB. So is the app's script.
Same size, so same cost? On the network, yes. After that, not at all. To see why, the course built two test pages and loaded each one in Chrome:
- One page loads a made-up app's 300 KB script. It's full of small widget classes, like a component library. When it starts, it draws every widget once, the way a framework draws a page.
- The other page shows one picture of almost exactly the same size: a PNG of random colors.
Chrome loaded both as if it were a phone. Every request waited one 150 ms round trip: the time for a message to reach the server and an answer to come back. Then the bytes arrived at 1.6 Mbps. That's the phone network Lighthouse uses. Chrome also ran the CPU 4 times slower than normal. First, the two downloads:
// Two files of almost the same size, over a phone-like network.const script = { bytes: 307718, downloadMs: 1536.8 };const picture = { bytes: 307678, downloadMs: 1537.5 }; console.log(`The script took ${script.downloadMs} ms to download.`);console.log(`The picture took ${picture.downloadMs} ms to download.`);- Line 1 is a comment.
- Lines 2 and 3 hold each file's size in bytes, and how long it took to download in milliseconds (ms). A millisecond is a thousandth of a second.
- Lines 5 and 6 print
The script took 1536.8 ms to download.andThe picture took 1537.5 ms to download.
Same size, same wait: about a second and a half each. On the network, a byte is a byte. Now look at what happened once the files had arrived. This is how long each page kept the main thread busy:
// How long each page kept the main thread busy, in ms, with the CPU 4 times slower.const scriptPage = { mainThreadMs: 171.1, javascriptMs: 121.1 };const picturePage = { mainThreadMs: 77.5, javascriptMs: 0 }; console.log(`The script's page: ${scriptPage.mainThreadMs} ms, ${scriptPage.javascriptMs} ms of it JavaScript.`);console.log(`The picture's page: ${picturePage.mainThreadMs} ms, ${picturePage.javascriptMs} ms of it JavaScript.`);const extra = scriptPage.mainThreadMs - picturePage.mainThreadMs;console.log(`The script's page needed ${extra.toFixed(1)} ms more.`);- Line 1 is a comment. The main thread is the head cook from lesson 6.1. It runs your JavaScript and works out styles, layout and paint, one job at a time.
- Line 2 holds the script page's recorded times. The main thread was busy for 171.1 ms, and 121.1 ms of that was JavaScript. Line 3 holds the picture page's: 77.5 ms, with no JavaScript at all.
- Lines 5 and 6 print
The script's page: 171.1 ms, 121.1 ms of it JavaScript.andThe picture's page: 77.5 ms, 0 ms of it JavaScript. - Line 7 subtracts one from the other. Line 8 prints
The script's page needed 93.6 ms more.
And the picture? Before the browser can show it, it has to decode it: unpack the file into the color of each pixel. Decoding the picture took 0.8 ms, all of it on worker threads. Worker threads are the helpers in the back of the kitchen from lesson 6.1. They work beside the main thread, so the main thread stayed free.
So the downloads cost the same. Then the script cost the main thread about 94 ms more. Why? A picture is ready once it's decoded. A script still has to be read, turned into steps the browser can follow, and then run:
Two parcels arrive at your door, the same size and the same weight. One is a framed picture: you unwrap it and hang it in a minute. The other is a flat-pack wardrobe. You read the instructions, work out the steps, and build it. That takes the whole evening.
- In real life: The delivery van, the same for both
- In the browser: The download
- In real life: Unwrapping the picture
- In the browser: Decoding an image
- In real life: Reading the wardrobe's instructions
- In the browser: Parsing the script
- In real life: Working out the steps
- In the browser: Compiling it
- In real life: Building the wardrobe
- In the browser: Executing it
You pay for a script twice. First its bytes are downloaded, like any file. Then the browser has to parse, compile and run them, and much of that work lands on the main thread. A picture of the same size pays for the download and a short decode.
Addy Osmani put it this way in 2018: "Byte-for-byte, JavaScript is still the most expensive resource we send to mobile phones, because it can delay interactivity in large ways." Here's why. Lesson 6.2 showed that the main thread does one task at a time. While it runs a script, a tap has to wait, and so does the next frame.
The rest of this lesson takes the script's cost apart:
- what parse, compile and execute mean, and which thread does each;
- how Chrome keeps compiled code between visits;
- why a slower phone pays more;
- how the cost grows with the size of the script, and how to load less at the start.
The JavaScript lessons Lazy parsing & preparsing and Startup: snapshots & code caching explain how V8 does this work. This lesson measures what it costs in Chrome.
Download, parse, compile, execute
Back to the wardrobe. As a script's bytes arrive, Chrome hands them to V8, its JavaScript engine. An engine is the part of a browser that runs JavaScript. V8 has three jobs to do:
- Parse: read the code's text and build a tree of its parts: functions, variables, calls.
- Compile: turn that tree into bytecode. Bytecode is a list of short, simple instructions that V8 can run one after another.
- Execute: run the bytecode. For the test app, that means building every widget.
Along the way, V8 also does garbage collection. It clears out memory the code no longer uses.
Worker threads read the script while it downloads
Which thread does all this? Think of the worker threads again, the helpers in the back of the kitchen. They can't touch the page, but they can read a script. V8 uses them for streaming: it parses and compiles a script file on a worker thread while the file is still downloading.
For most connection speeds, V8 reads faster than the bytes arrive. Its 2019 article says it is "done parsing+compiling a few milliseconds after the last script bytes are downloaded."
The test pages show this. Over the phone network, V8's streaming task stayed open on a worker for the whole 1.5 second download. Most of that time it was waiting for bytes. It parsed for 19 ms in all.
Functions left for later are compiled on the main thread
So the main thread is free? Not quite. The script's top level, the code that isn't inside any function, has to be compiled before anything runs. But a big script holds many functions, and a page may never call some of them. So for each function, V8 chooses. It can compile it now. Or it can give it a quick read, to check the code is valid, and leave it for later. This quick read is called preparsing.
When a function that was left for later is called for the first time, V8 parses it fully and compiles it, right then. That happens on the main thread, in the middle of running the script. V8's article on compile hints says why that hurts: "the main thread cannot proceed until the function is compiled."
A new recipe book arrives at the café. While the head cook works, a helper skims it page by page and checks nothing is missing. Later, the first order comes in for a dish from the book. Now the cook has to stop and read that recipe properly before she can start. The guest waits while she reads.
- In real life: The helper skimming the book as it arrives
- In the browser: A worker thread streaming the script
- In real life: A quick look to check no page is missing
- In the browser: Preparsing a function
- In real life: The first order for a dish from the book
- In the browser: The first call to that function
- In real life: The cook stopping to read the recipe properly
- In the browser: Compiling it on the main thread
How much landed on the main thread for the test app? The course served the 300 KB script from the same computer, so the download took no time. The CPU was 4 times slower again. Here is the main thread's share:
// The 300 KB script's work on the main thread, in ms, with the CPU 4 times slower.const mainThread = { parse: 18.1, compile: 46.8, execute: 52.7, gc: 3.4 }; const total = mainThread.parse + mainThread.compile + mainThread.execute + mainThread.gc;const compiling = mainThread.parse + mainThread.compile;console.log(`The main thread spent ${total.toFixed(1)} ms on the script.`);console.log(`Parsing and compiling took ${Math.round((compiling / total) * 100)}% of it.`);- Line 1 is a comment. Line 2 holds the recorded time for each kind of work.
gcis garbage collection. - Line 4 adds all four together. Line 5 adds only parsing and compiling.
- Line 6 prints
The main thread spent 121.0 ms on the script. - Line 7 prints
Parsing and compiling took 54% of it.
And here is what the worker threads did in the same load:
// The same load: the script's work on the worker threads, in ms.const workers = { parse: 2, compile: 2.1 }; console.log(`Worker threads parsed and compiled for ${(workers.parse + workers.compile).toFixed(1)} ms.`);- Line 2 holds the recorded parsing and compiling on the worker threads.
- Line 4 adds them up and prints
Worker threads parsed and compiled for 4.1 ms.
So more than half of the main thread's time went on parsing and compiling, and the workers did very little. Why? The app draws every widget as it starts. That calls each widget's constructor and its render method, so most of the script's functions run at once. Each one was compiled on the main thread the first time it was called.
Why it matters: streaming only helps with the code V8 compiles up front. If your start-up calls most of your functions, their compiling still lands on the main thread while the person waits.
Read this table across. Each row is one step: what happens, and where it ran for the test app's script.
| The step | What happens | Where it ran |
|---|---|---|
| Download | The file's bytes travel from the server to the browser. | On the network. No JavaScript work happens yet. |
| Parse | V8 reads the code and builds a tree of its parts. | The top level on a worker thread, while the file streamed in. Each function left for later on the main thread, when it was first called. |
| Compile | V8 turns that tree into bytecode, the instructions it runs. | The same threads as parsing, at the same moments. |
| Execute | V8 runs the bytecode. Here, it builds every widget. | On the main thread. |
Seeing compile time from the page
A page can measure some of this for real visitors. Lesson 6.2 met long tasks. A long animation frame is a frame that was held up for more than 50 ms. The Long Animation Frames API reports each one, with a list of the scripts that ran in it.
new PerformanceObserver((list) => { for (const frame of list.getEntries()) { for (const script of frame.scripts) { const compiling = script.executionStart - script.startTime; console.log(script.sourceURL, `compiling ${compiling.toFixed(1)} ms`, `in all ${script.duration.toFixed(1)} ms`); } }}).observe({ type: "long-animation-frame", buffered: true });- Line 1 makes a
PerformanceObserver: an object the browser calls with new performance entries. - Line 8 asks it for long animation frames, including any from before it started (
buffered: true). - Lines 2 and 3 go through each long frame, and each script in that frame.
- Line 4 subtracts the script's
startTimefrom itsexecutionStart. MDN describesexecutionStartas "the time when the script compilation finished and execution started". So the gap is the compiling done before the script began to run. - Line 5 prints the script's address, that gap, and how long the script took in all.
For the 300 KB script, the page saw compiling end 0.4 ms after the script's entry started. The script took 120.1 ms in all. Only 0.4 ms of compiling? Streaming had already compiled the top level. The functions compiled later, while the script ran, count as running.
Code caching
Asha likes the shop. She comes back the next day, and the day after. Does V8 compile the same script from scratch every time?
No. Chrome can keep the compiled code and use it again. This is called code caching. It builds on the HTTP cache, where the browser keeps copies of files it downloaded before.
The first time a cook makes a new dish, she works out every step from the recipe card. The second time, she works it out again, and writes the steps down in her own short notes. From then on, she reads her notes and starts cooking at once. If the café swaps in a new card, she starts again from the beginning.
- In real life: The recipe card, kept in a drawer
- In the browser: The script, kept in the HTTP cache
- In real life: Working out the steps
- In the browser: Compiling
- In real life: Writing her own notes, the second time
- In the browser: The warm run: saving the compiled code with the file
- In real life: Cooking straight from her notes
- In the browser: The hot run: reading the saved code instead of compiling
- In real life: A brand-new recipe card
- In the browser: A new script URL, which starts again from cold
V8's Leszek Swirski describes three runs of the same script:
- The cold run, the first visit: "Chrome downloads it and gives it to V8 to compile." Chrome also keeps the file in the HTTP cache.
- The warm run, the second visit. Chrome takes the file from the cache, and V8 compiles it again. This time, "the compiled code is serialized, and is attached to the cached script file as metadata." Serialized means turned into bytes that can be saved.
- The hot run, the third visit: "V8 deserializes the metadata and can skip compilation." It reads the saved code back instead of compiling.
The course loaded the 300 KB script three times in a row. Each time, it started Chrome afresh with the same profile, the way a person comes back another day. The CPU was 4 times slower:
// The 300 KB script on three visits in a row, with the CPU 4 times slower.// Each visit's work on the main thread, in ms. cache: saving or reading compiled code.const visits = { cold: { parse: 20.4, compile: 48.6, cache: 0, execute: 49.9, gc: 5.4 }, warm: { parse: 0, compile: 5.9, cache: 24.9, execute: 37.7, gc: 3.7 }, hot: { parse: 0, compile: 0.8, cache: 1.1, execute: 27.2, gc: 4.8 },}; for (const [visit, work] of Object.entries(visits)) { const total = Object.values(work).reduce((sum, ms) => sum + ms, 0); const compiling = work.parse + work.compile; console.log(`The ${visit} visit: ${total.toFixed(1)} ms, ${compiling.toFixed(1)} ms of it parsing and compiling.`);}- Lines 1 and 2 are comments.
cacheis time spent saving or reading compiled code. - Lines 4 to 6 hold each visit's recorded work on the main thread.
- Line 10 adds up each visit's work. Line 11 adds its parsing and compiling.
- Line 12 prints
The cold visit: 124.3 ms, 69.0 ms of it parsing and compiling.ThenThe warm visit: 72.2 ms, 5.9 ms of it parsing and compiling.andThe hot visit: 33.9 ms, 0.8 ms of it parsing and compiling.
The hot visit is the reward. Parsing and compiling fell from 69.0 ms to 0.8 ms. Instead, V8 read 957,520 bytes of saved code. That's about three times the size of the script itself. The saved code also holds the functions compiled while the script ran, because Chrome saves it only after the script has run.
The warm visit pays to save
The warm visit is a surprise. It compiled again, yet its main thread compiled much less than the cold visit's did. Here is where its time went:
// The warm visit, in ms: where it compiled, and what saving the code cost.const warm = { compileOnMainThread: 5.9, compileOnWorkers: 10.6, savingTheCode: 24.9 }; console.log(`Compiling on the main thread: ${warm.compileOnMainThread} ms`);console.log(`Compiling on worker threads: ${warm.compileOnWorkers} ms`);console.log(`Saving the compiled code: ${warm.savingTheCode} ms`);- Line 2 holds three recorded times from the warm visit.
- Lines 4 to 6 print
Compiling on the main thread: 5.9 ms,Compiling on worker threads: 10.6 msandSaving the compiled code: 24.9 ms.
Most of the compiling moved to worker threads. Chromium's source points to a likely reason. During a visit, Chrome notes which functions were compiled, and saves that list with the cached file. These notes are called local compile hints. On the next visit, V8 can compile those functions up front, on a worker, while the script streams in.
Then saving the code cache cost the main thread 24.9 ms. That's the warm visit's price. The hot visit, and every visit after it, gets the work back.
What sends a visitor back to cold
Code caching works without any help from you. But a few things throw the saved code away, or stop it being made:
- A new URL. V8 says changing a script's URL, "including any query parameters!", creates "a new cold cache entry." So
app.js?v=2starts cold. - New code. Code you just deployed isn't cached yet. When the server sends a changed file, Chrome clears the old code cache.
- A long gap. V8 says code caching starts only if the first two visits happen within 72 hours.
- A tiny file. Chrome doesn't cache code for scripts with less than 1 KiB (1,024 bytes) of source.
- An inline script. A script written inside the HTML has no file of its own. Chrome attaches its cache to the HTML page instead, so any change to the page throws it away.
Why it matters: each deploy sends every visitor back to the cold run. V8 suggests putting code that rarely changes, like libraries, in a file of its own. Then it stays cached when your own code changes.
A fast phone and a slow one
Asha's phone is fairly new. Her friend Ravi's is older and cheaper. They open the same page and download the same script. Does it cost them the same?
No. Running code is work for the CPU, the phone's processor. V8's 2019 article says it plainly: "execution is CPU-bound". A slower CPU takes longer over every step. V8 timed Reddit's JavaScript on three phones:
- A median phone, a Moto G4, took 3 to 4 times as long as a high-end one, a Pixel 3.
- A low-end phone, an Alcatel 1X, took over 6 times as long.
Two cafés cook from the same recipe card. One has a big kitchen, a hot stove and sharp knives. The other has one cook with no helper, a small stove and a blunt knife. The same dish takes four times as long there, and the card hasn't changed at all.
- In real life: The same recipe card
- In the browser: The same script, byte for byte
- In real life: A big kitchen, a hot stove, sharp knives
- In the browser: A fast laptop or a high-end phone
- In real life: One cook, a small stove, a blunt knife
- In the browser: A cheaper phone with a slower CPU
- In real life: Every step taking longer
- In the browser: Parsing, compiling and executing all taking longer
You can't test on every phone, but you can slow your own CPU down. Lighthouse runs the CPU 4 times slower by default, to stand in for a mid-tier phone. The course did the same on its Apple M1 Pro computer, at three speeds:
// The 300 KB script's JavaScript on the main thread, in ms.const fullSpeed = 27.7;const slower = { 4: 121, 6: 187.2 }; for (const [slowdown, ms] of Object.entries(slower)) { console.log(`With the CPU ${slowdown} times slower: ${ms} ms, ${(ms / fullSpeed).toFixed(1)} times as long.`);}- Line 1 is a comment.
- Line 2 holds the 300 KB script's recorded JavaScript on the main thread at full speed. Line 3 holds it with the CPU 4 and 6 times slower.
- Line 6 divides each time by the full-speed time. It prints
With the CPU 4 times slower: 121 ms, 4.4 times as long.andWith the CPU 6 times slower: 187.2 ms, 6.8 times as long.
The main thread's time grew with the slowdown. The worker threads' parsing and compiling didn't: it stayed between 4.1 and 6.7 ms at every speed. The slowdown landed on the main thread.
One warning. DevTools says "Throttling is relative to your computer's capabilities." Four times slower than a fast laptop isn't the same as four times slower than an old one. And DevTools "can't truly simulate the CPUs of mobile devices", because phones are built very differently from laptops.
Why it matters: a slowdown gives you a feel for a phone, not the real thing. Ravi's phone may be slower still. Test on a real, cheaper phone when you can.
Main-thread time per kilobyte
So far, one script of 300 KB. What happens as an app grows? The course built the same kind of app in three sizes: 100 KB, 300 KB and 1000 KB. A bigger one simply has more widgets. Each one stands in for a bundle: a single script file that a build tool makes by joining many smaller files of code. Here they are with the CPU 4 times slower:
// Three sizes of the same app, with the CPU 4 times slower.const bundles = { "100 KB": { bytes: 102920, ms: 44.4 }, "300 KB": { bytes: 307718, ms: 121 }, "1000 KB": { bytes: 1025948, ms: 374.6 },}; for (const [name, { bytes, ms }] of Object.entries(bundles)) { const kilobytes = bytes / 1000; console.log(`The ${name} bundle: ${ms} ms, or ${(ms / kilobytes).toFixed(2)} ms for each KB.`);}- Lines 3 to 5 hold each bundle's size in bytes, and its recorded JavaScript on the main thread.
- Line 9 turns bytes into kilobytes. Here a kilobyte (KB) is 1,000 bytes.
- Line 10 divides the time by the kilobytes. It prints
The 100 KB bundle: 44.4 ms, or 0.43 ms for each KB.,The 300 KB bundle: 121 ms, or 0.39 ms for each KB.andThe 1000 KB bundle: 374.6 ms, or 0.37 ms for each KB.
Each kilobyte cost about the same: roughly 0.4 ms of main thread. So the cost grew about in step with the size. That figure belongs to this app. A script that does more as it starts costs more for each kilobyte, and one that does less costs less. Measure your own.
Why it matters: the biggest bundle ran 374.6 ms of JavaScript on the main thread as the page started. A tap in that time has to wait. Compare every size and speed the course recorded:
121.0 ms0.39 ms153.4 ms4.1 msThe 300 KB bundle is 307,718 bytes, with 668 widget classes. With the CPU 4 times slower, it ran 121.0 ms of JavaScript on the main thread. That's 0.39 ms for each KB, and 4.4 times as long as at full speed.
Or run a bundle in your own browser. Each button builds a bundle as a string of code. new Function turns that string into a function, and the browser has to parse the code to do it. Then the button runs the function. Line 4 of the panel's code is the first timed step, and line 6 is the second.
// Build the bundle; return its size instead of writing to the page.const source = bundle(300).replace("document.body.dataset.size = String(html.length);", "return html.length;");const started = performance.now();const app = new Function(`// run ${run}\n${source}`);const created = performance.now();app();const finished = performance.now();console.log(created - started, finished - created);not yetnot yetnot yetEach button builds a bundle, has your browser read it with new Function, and runs it once. Your times depend on your device.
Load less at the start
If every kilobyte costs main-thread time, the cheapest kilobyte is one the page doesn't need yet. Addy Osmani's advice is to "only load JavaScript needed for the current page" and to "lazy-load the rest with code-splitting."
You move into a new flat. Twenty boxes of your things wait in storage. Tonight you only need the kettle, a mug and your bedding, so you bring those three boxes home and unpack them. The rest stay in storage until the day you need them.
- In real life: All twenty boxes in storage
- In the browser: All of the app's code
- In real life: The three boxes for tonight
- In the browser: The code the page needs to start
- In real life: A box fetched on the day you need it
- In the browser: A file loaded later with
import() - In real life: Unpacking a box
- In the browser: Parsing, compiling and running a file
Two names go with this idea:
- Code splitting cuts one big bundle into several smaller files.
- Lazy loading fetches a file only when the page needs it.
In JavaScript, import() does the loading. It loads a module, a JavaScript file that shares its functions with export. MDN says import() lets you load a module "conditionally or on demand." Here the shop loads its checkout only when someone opens the cart:
// Load the checkout's code only when someone opens the cart.const cartButton = document.querySelector("#open-cart"); cartButton.addEventListener("click", async () => { const { showCheckout } = await import("./checkout.js"); showCheckout();});- Line 1 is a comment. Line 2 finds the cart button.
- Line 4 tells the button to run a function each time someone clicks it.
- Line 5 calls
import(). The first time it runs, the browser fetches checkout.js, then parses, compiles and runs it.awaitwaits until it's ready. - Line 6 calls
showCheckout, a function from that file.
At the start, the page skips checkout.js completely: no download, no parsing, no compiling. People who never open the cart never pay for it. People who do pay later, when they ask for it. Smaller files help streaming too: V8 can stream and compile each one on its own.
The JavaScript lessons Dynamic import & top-level await and Loading performance show how to set this up.
Where the JavaScript lessons go deeper
This lesson measured what JavaScript costs. These lessons in the JavaScript course explain the engine work behind it:
- Loading performance: budgets, code splitting and lazy loading, async, defer and modules, and caching.
- Lazy parsing & preparsing: the preparser, functions read twice, functions that run right away, and explicit compile hints.
- Startup: snapshots & code caching: startup snapshots, the code cache, compiling while downloading, and cache-friendly code.
Which cost does it cut?
Each fix below cuts one kind of cost. For each one, ask what it changes. Does it shrink the bytes on the network? Does it save the main thread from compiling? Or does it make the code do less once it runs?
- Compressing the bundle with Brotli
- Serving it from a server closer to the user
- Keeping the script's URL the same between visits
- Deploying changes less often
- Moving a big inline script into its own file
- Doing less work in each widget's render method
- Building fewer rows in each widget's constructor
Sort each fix by the cost it cuts.
Common mix-ups
- "A kilobyte is a kilobyte." No. The script and the picture downloaded in the same time. Then the script ran 121.1 ms of JavaScript on the main thread. The picture took 0.8 ms to decode, on worker threads.
- "Compression makes JavaScript cheaper to run." No. It only shrinks the download. Once the bytes are unpacked, the browser still parses all of them. Addy Osmani's example: 350 KB of compressed script "bloats up to over 1MB of script a browser needs to process."
- "Code caching helps from the second visit." No. The second visit compiled again and spent 24.9 ms saving the code. Only the third visit read it.
- "Parsing is the slow part." No. For the 300 KB script, parsing took 18.1 ms of the main thread's 121.0. Compiling and executing each took longer.
- "Four times slower in DevTools is a phone." No. It slows your own CPU, whatever that is. DevTools says it can't truly simulate a phone's CPU.
- "Streaming means compiling never blocks." No. A function left for later is compiled on the main thread when it's first called. In the 300 KB script's load, the main thread spent 46.8 ms compiling.
Practice exercises
Answer first, then use the hints.
Here are the same three bundles, recorded with the CPU 6 times slower. What does the last line print?
// Three sizes of the same app, with the CPU 6 times slower.
const bundles = {
"100 KB": { bytes: 102920, ms: 68.4 },
"300 KB": { bytes: 307718, ms: 187.2 },
"1000 KB": { bytes: 1025948, ms: 582.6 },
};
for (const [name, { bytes, ms }] of Object.entries(bundles)) {
const kilobytes = bytes / 1000;
console.log(`The ${name} bundle: ${ms} ms, or ${(ms / kilobytes).toFixed(2)} ms for each KB.`);
}The 1000 KB bundle: 582.6 ms, or 0.57 ms for each KB. A slower CPU spends more time on every kilobyte: 0.57 ms here, against 0.37 ms at 4 times slower.
Now each visit is shown as a share of the cold visit. What does the last line print?
// The 300 KB script on three visits in a row, with the CPU 4 times slower.
// Each visit's work on the main thread, in ms. cache: saving or reading compiled code.
const visits = {
cold: { parse: 20.4, compile: 48.6, cache: 0, execute: 49.9, gc: 5.4 },
warm: { parse: 0, compile: 5.9, cache: 24.9, execute: 37.7, gc: 3.7 },
hot: { parse: 0, compile: 0.8, cache: 1.1, execute: 27.2, gc: 4.8 },
};
for (const [visit, work] of Object.entries(visits)) {
const total = Object.values(work).reduce((sum, ms) => sum + ms, 0);
console.log(`The ${visit} visit took ${Math.round((total / 124.3) * 100)}% of the cold visit's time.`);
}The hot visit took 27% of the cold visit's time. Reading the saved code instead of compiling cut the main thread's JavaScript by almost three quarters.
In the playground, how many milliseconds of JavaScript did the 1000 KB bundle run on the main thread at full speed?
87.8 ms. Even at full speed, that's more main-thread time than a 50 ms long task.
Asha visits the shop on Monday and Tuesday. On Wednesday morning, the shop deploys a changed app.js at the same URL. Asha visits again that afternoon. Which of V8's three runs is her Wednesday visit?
cold. The new app.js isn't cached yet, so V8 compiles it from scratch. If Asha comes back on Thursday, that visit is warm.
Which comment, at the top of a script, asks Chrome to compile every function in the file up front?
//# allFunctionsCalledOnLoad. From Chrome 136, it asks V8 to compile the whole file up front, on a worker. V8 says to use it sparingly, because compiling too much costs time and memory.
V8 suggests splitting a bundle into smaller ones once it passes a certain size. What size is that?
About 50–100 kB. V8 says that if a bundle is bigger, you should split it into smaller bundles.
Check your understanding
For each question, ask where the cost lands: on the network, on the main thread, or on a worker. Then ask whether a cache can skip it.
Question 1 of 7What does the last line of the picture example print?
Read the code, then predict// How long each page kept the main thread busy, in ms, with the CPU 4 times slower. const scriptPage = { mainThreadMs: 171.1, javascriptMs: 121.1 }; const picturePage = { mainThreadMs: 77.5, javascriptMs: 0 }; console.log(`The script's page: ${scriptPage.mainThreadMs} ms, ${scriptPage.javascriptMs} ms of it JavaScript.`); console.log(`The picture's page: ${picturePage.mainThreadMs} ms, ${picturePage.javascriptMs} ms of it JavaScript.`); const extra = scriptPage.mainThreadMs - picturePage.mainThreadMs; console.log(`The script's page needed ${extra.toFixed(1)} ms more.`);Choose an answer to see the explanation.
Question 2 of 7In 2019, what did V8 call the dominant costs of processing scripts?
Choose an answer to see the explanation.
Question 3 of 7V8 left a function for later, and the page now calls it for the first time. Where is it compiled?
Choose an answer to see the explanation.
Question 4 of 7What does the second line of the code caching example print?
Read the code, then predict// The 300 KB script on three visits in a row, with the CPU 4 times slower. // Each visit's work on the main thread, in ms. cache: saving or reading compiled code. const visits = { cold: { parse: 20.4, compile: 48.6, cache: 0, execute: 49.9, gc: 5.4 }, warm: { parse: 0, compile: 5.9, cache: 24.9, execute: 37.7, gc: 3.7 }, hot: { parse: 0, compile: 0.8, cache: 1.1, execute: 27.2, gc: 4.8 }, }; for (const [visit, work] of Object.entries(visits)) { const total = Object.values(work).reduce((sum, ms) => sum + ms, 0); const compiling = work.parse + work.compile; console.log(`The ${visit} visit: ${total.toFixed(1)} ms, ${compiling.toFixed(1)} ms of it parsing and compiling.`); }Choose an answer to see the explanation.
Question 5 of 7On V8's warm run, the second time Chrome loads a script, what happens?
Choose an answer to see the explanation.
Question 6 of 7Why can adding ?v=2 to a script's URL slow down the next visits?
Choose an answer to see the explanation.
Question 7 of 7In the recordings, what did slowing the CPU do to the work on the worker threads?
Choose an answer to see the explanation.
Key takeaways
- A script's bytes cost a download, then parsing, compiling and running, much of it on the main thread.
- A picture of the same size cost the same download, then a short decode on worker threads.
- V8 streams a script on a worker as it downloads. A function left for later is compiled on the main thread when first called.
- Code caching: the second visit saves the compiled code, and the third skips compiling. Keep URLs stable and deploy less often.
- A slower CPU makes the main thread's time grow. DevTools throttling only imitates a phone.
- The cost grew with the size: about 0.4 ms a kilobyte at 4x for this app. Load only what the start needs, and measure yours.
Remember the one-liner.
Every kilobyte of JavaScript is paid for twice: once on the network, and again on the main thread.
Sources
The facts on this page come from these docs and articles:
- The Cost Of JavaScript In 2018, Addy Osmani.
- The cost of JavaScript in 2019, Addy Osmani and Mathias Bynens, V8.
- Blazingly fast parsing, part 2: lazy parsing, Toon Verwaest and Marja Hölttä, V8.
- Giving V8 a Heads-Up: Faster JavaScript Startup with Explicit Compile Hints, Marja Hölttä, V8.
- Long animation frame timing, MDN.
- PerformanceScriptTiming: executionStart property, MDN.
- Code caching for JavaScript developers, Leszek Swirski, V8.
- v8_local_compile_hints_producer.cc, Chromium source.
- Throttling, Lighthouse docs.
- Performance features reference, Chrome DevTools.
- import(), MDN.
Coming next: The Performance panel, the first lesson of Measuring in the lab. It records a trace like the ones in this lesson, with throttling, and shows your real visitors' data beside it.