Experiments / 03
JavaScript
Why is a kilobyte of script dearer than a kilobyte of image?
The problem
A 200 KB image and a 200 KB script take the same time to download. After that they part ways. The image is decoded and drawn, largely off to the side. The script has to be parsed, compiled and executed, and all of that happens on the main thread: the one thread that also handles taps, typing, scrolling and drawing the page.
So “JavaScript is expensive” is not mainly about bytes. It’s about how long the page is too busy to respond. The two experiments below let you watch that happen.
1 · Download, parse, execute
Start with the phone preset. Compare the heavy page with the optimised one, then switch the CPU to Desktop and watch the heavy page’s problem shrink. The network never changed.
- Page responsive at
- 1.28 sheavy: 4.69 s
- Main-thread work
- 655 msheavy: 4.00 s
- Blocking time
- 555 msheavy: 3.80 s
- Script at startup
- 99 KBheavy: 570 KB
- Download
- Parse & compile
- Execute
- Page can’t respond
Heavy page
Optimised page
- Download184 ms · same
- Image: main thread≈ 0 ms
- Script: parse & compile1.57 s
- Script: execute1.40 s
Heavy page: the last script has arrived by 822 ms, but the page isn’t responsive until 4.69 s. The 3.86 s in between is the CPU parsing and running 1995 KB of code. The download was only 570 KB because it was compressed; the CPU works on the uncompressed code.
Same page on a desktop would need 1000 ms of main-thread work, not 4.00 s. The network didn’t change; the CPU did. Test on a throttled or real mid-range phone, not just your laptop.
Optimised: removing 45% unused code, loading only 40% of the rest up front, running third-party scripts after the page is usable. The page is responsive 3.40 s sooner and 351 KB of script isn’t loaded until needed. The faded blocks are third-party work that still happens, just later.
Illustrative rates: 0.25 ms per KB of uncompressed code to parse and compile on a desktop; compression ≈ 3.5×.
What happens to a script
- Download
- Bytes over the network. Depends on compressed size. Gzip or Brotli typically shrinks JavaScript by around three to four times, which is why this bar stays short even for big bundles.
- Parse and compile
- The engine reads the uncompressed source and turns it into something it can run. Compression doesn’t help here: a 450 KB download is well over a megabyte of code to process. Engines do some of this work on background threads and cache results, so treat the numbers here as illustrative.
- Execute
- Your code runs: building state, creating components, attaching listeners, hydrating server-rendered HTML. This is usually where a framework app spends its startup time.
- Main thread
- Everything above competes with input handling, style, layout and painting. While the main thread is busy, the page can’t respond. That is the red hatched lane.
The three techniques in the widget
- Remove unused code. Tree-shaking, dropping dead dependencies, importing one function instead of a whole library. Chrome DevTools’ Coverage tab shows how much of a bundle never ran. It typically shows a lot.
- Split by route. Ship the code this page needs now and load the rest on demand. Beware splitting too finely: more chunks mean more requests, and a waterfall of dependent loads can undo the gain.
- Load third parties late. Analytics, chat widgets and tag managers are often a large share of main-thread time. Running them when the page is idle doesn’t make them free. It moves their cost out of the moment the user is trying to interact.
async and defer stop a script from blocking HTML parsing. They don’t make the script
cheaper to run, which brings us to the misconception below.
2 · One thread, one task at a time
Move the tap into the middle of the 600 ms task and see how long it waits. Then slice the work and watch the wait collapse. At the bottom you can freeze this tab on purpose.
- Tap → response
- 487 msNeeds improvement
- Longest task
- 600 ms
- Blocking time
- 550 ms
- Frames dropped
- 36
- Your JavaScript
- Tap handler
- Tap waiting
- Dropped frame
Click the timeline to move the tap.
One 600 ms task. The tap at 250 ms can’t be handled until the task ends, so it waits 450 ms. With the handler and the next frame the user waits 487 ms to see anything: needs improvement.
1 long task (over 50 ms) add 550 ms of blocking time: each task’s time beyond 50 ms counts.
Press a button. Watch the spinners and the frame counter, and try typing straight away.
The first button keeps the main thread busy for half a second. The second does the same amount of work but yields between slices. A CSS transform animation usually carries on through a busy main thread; anything driven by JavaScript stops.
Interaction = input delay + handler (20 ms) + next frame. INP thresholds: good ≤ 200 ms, poor > 500 ms.
Long tasks and responsiveness
- A long task is any task that holds the main thread for more than 50 ms. The browser can’t interrupt it, so any tap, keypress or scroll that arrives meanwhile waits.
- Total Blocking Time adds up the time beyond 50 ms in every long task. It’s a lab measure of how much a page blocks while loading.
- Interaction to Next Paint (INP) is the field measure: the time from a user’s interaction to the next frame being drawn. The thresholds used for Core Web Vitals are good at 200 ms or less and poor above 500 ms.
How to break up work
-
Yield to the main thread between slices. Use
scheduler.yield()where it’s supported, otherwisesetTimeout(…, 0). The demo above does this. - Move work off the main thread to a Web Worker when it doesn’t need the DOM.
- Do less. The cheapest work is the work you don’t do: smaller bundles, less hydration, less rendering.
A motion lesson hiding in the demo. The CSS spinner kept turning while the JavaScript-driven one
froze. Animations of transform and opacity can run on the compositor, away from the main
thread. It’s one reason this site only animates those two properties.
Common misconceptions
“It’s only 150 KB. That’s smaller than a photo.”
Bytes are the wrong unit. A photo’s cost is mostly download. A script’s cost is mostly what happens after it arrives: parse, compile and run on the main thread, scaled by how slow the device is.
“I used async/defer, so scripts are free.”
They stop blocking the parser. They still run on the main thread once downloaded, and a long task will block input just the same.
“It’s fast on my laptop.”
Your laptop isn’t your user. Flip the CPU from Desktop to Phone and nothing about the network changes, but the main-thread time grows several times over. Test on a throttled CPU or a real mid-range phone.
See it on a real page
- Open DevTools, then Performance. Set CPU throttling to 4× or 6× slowdown, record a page load, and look at the Main track. Tasks over 50 ms are marked with a red corner.
- Open Coverage (Command menu, “Show Coverage”), reload, and see how much of each script ran.
-
Or watch from the page:
new PerformanceObserver((list) => { for (const e of list.getEntries()) console.log('long task', Math.round(e.duration), 'ms'); }).observe({ type: 'longtask', buffered: true }); - For real users, collect INP with Google’s
web-vitalslibrary, because a lab run only shows what you tested.
Model assumptions
What these simulations simplify
- Parse and compile cost 0.25 ms per KB of uncompressed code on a desktop, with phones 4× and 6× slower, and compression of 3.5×. These are illustrative, not benchmarks. Real engines stream-parse on background threads, cache compiled code and optimise hot paths.
- Startup execution scales with the amount of code that runs (about 0.78 ms per KB of compressed app code on a desktop). Real apps vary enormously.
- Scripts are deferred, discovered when the HTML arrives, and downloaded in parallel sharing bandwidth. Main-thread work runs one task at a time.
- “Responsive” is when the last startup-critical task finishes. Late third-party work is drawn faded and isn’t counted.
- Blocking time counts critical tasks after a first paint assumed 100 ms after the HTML arrives.
- In the main-thread model a tap is handled at the first gap between tasks. A yield costs 1 ms. A frame is dropped if the thread stays busy for more than a whole frame (16.7 ms) past its deadline.