01 — The Single Bundle Problem

A 2MB JavaScript bundle sits between the user's tap and the page. The browser fetches it, hands it to V8, then V8 parses, compiles, and executes — every byte, on the main thread, before anything reacts. Until V8 yields, clicks queue. Scroll queues. The page is paint but no pulse.

That is exactly what the flame chart on the right is showing in step 1: one enormous block sitting on the main thread for roughly 2 seconds, capped with a red warning triangle. The triangle marks a long task — a contiguous block that ran for more than 50 ms without yielding to the event loop. The Long Tasks API names a 50 ms ceiling not because 50 ms is bad, but because anything past that window is long enough to swallow an incoming pointerdown event and the paint that should follow it.

The 2MB ~2 s rule of thumb comes from V8's “Cost of JavaScript” updates: parse-and-compile costs roughly 1MB / second on mid-tier Android, and execution piles on another 3050%. Every kilobyte of JS on the critical path costs roughly 1 ms of TTI on a phone someone actually owns. That number is not a benchmark — it is a budget.

Three escape routes line up in the next five steps. Split the bundle so only this route's code blocks rendering. Defer the chunks the user will not see immediately. Move pure computation onto a parallel thread. Each one re-parks the same bytes in a different time slot — and a different time slot is not the same work.

Glance right: the flame chart is dimmed and red. As you scroll, each step transforms it.

02 — Route-Based Code Splitting

A typical SPA bundle contains the framework runtime, the router, the shell layout, and every page component. But the user lands on one route. Route-based code splitting exploits that — ship the framework + shell + this-route's code, and lazy-load each other route only on navigation. The blocking portion of the chart drops from one 2.1 s block to three smaller blocks with idle gaps between them.

You hand the bundler a dynamic import() and it does the splitting for you. Webpack, Vite, esbuild, and Rollup all emit a separate chunk per import() expression:

TS
1const Dashboard = lazy(() => import("./routes/Dashboard"));
2const Settings = lazy(() => import("./routes/Settings"));
3
4function App() {
5 return (
6 <Suspense fallback={<ShellSkeleton />}>
7 <Routes>
8 <Route path="/dashboard" element={<Dashboard />} />
9 <Route path="/settings" element={<Settings />} />
10 </Routes>
11 </Suspense>
12 );
13}

The same trick works at finer grain — a heavy modal you only open on click:

TS
1async function openHeavyModal() {
2 const { HeavyModal } = await import("./HeavyModal");
3 mount(HeavyModal);
4}

Common mistake: splitting too granularly. Every chunk is a network round-trip. Twenty 19KB chunks is twenty slow handshakes. The sweet spot is route-level splitting with shared vendor chunks — three to eight chunks, not fifty.

Where to split beyond routes? Heavy libraries used by only some pages: a charting library only on the analytics dashboard, a rich text editor only in the compose view, a PDF renderer only on download. The heuristic — if a dependency is > 30KB and used by < 50% of page views, it is a split candidate.

Scroll to step 3 — the flame chart on the right now shows three blocks. One is yellow. That one is wrong.

03 — Defer the Non-Critical

charting-lib.js is 340KB. It lives in a dashboard widget the user must scroll to see. It contributes nothing to first paint. Right now, even after route-splitting, it parks on the critical path for ~330 ms because nothing tells the browser it can wait.

The principle is direct: the same bytes in a different time slot are not the same work. Bytes before first paint cost TTI directly. Bytes after cost almost nothing. The fix is the same dynamic import() you used for routes, fired by a visibility signal:

TS
1useEffect(() => {
2 const io = new IntersectionObserver(async ([entry]) => {
3 if (!entry.isIntersecting) return;
4 io.disconnect();
5 const { renderChart } = await import("charting-lib");
6 renderChart(chartRef.current!);
7 });
8 io.observe(chartRef.current!);
9}, []);

For non-bundler contexts, the older script-element pattern still applies:

HTML
1<script type="module" src="/dashboard.js"></script>
2<script src="/charting-lib.js" defer></script>
3<script src="/analytics.js" async></script>

defer waits for the HTML document to parse, then runs in document order. async runs whenever the network completes — order is not guaranteed. <script type="module"> defers by default (modules are always deferred) and is the modern target.

Right after the option above resolves, the flame chart redraws: the yellow block lifts and parks below a dashed line labelled First Paint. Same bytes, later slot. TTI drops to ~1.4 s.

When not to defer: code that runs on the literal first user interaction. If the user lands on /checkout and the very next thing they touch is the checkout form, deferring the checkout module produces a dead state. The right cut is not on this route — not not on this page right now.

04 — Yielding the Main Thread

Splitting and deferring move work in time. But sometimes the work has to run during initial load, just not all at once. A 280 ms hydration block is still a 280 ms hydration block — and that means INP-killing latency the moment a user taps during it.

The fix is to yield: break a long task into chunks under 50 ms each, and let the browser process pending events between them. Three APIs cover the modern surface, in order of recency:

TS
1// Run only when the browser is actually idle (timeout caps the wait)
2requestIdleCallback(() => warmCacheFor("/dashboard"), { timeout: 2000 });
3
4// Priority-tagged scheduling — canonical 2024 API (Chrome 94+, Firefox 110+)
5scheduler.postTask(() => sendAnalytics(), { priority: "background" });
6
7// Mid-loop yield — Chrome 129+ only at writing
8async function hydrateChunks(components) {
9 for (const c of components) {
10 c.hydrate();
11 await scheduler.yield();
12 }
13}

requestIdleCallback is the long-running default — broad support, no priority knobs, and timeout is the only safety net against starvation. scheduler.postTask is the priority-aware replacement: user-blocking (urgent), user-visible (default), background (analytics, prefetch). scheduler.yield() (Chrome 129+, Chromium-only at writing) lets a single long-running function yield to input mid-loop without going to the back of the macrotask queue — its remaining work runs before lower-priority tasks but after user-input events.

Why not setTimeout(0)? It yields, but setTimeout callbacks go to the back of the macrotask queue. If other tasks are queued (third-party scripts, microtasks, postMessage callbacks), your remaining work waits behind them. scheduler.yield() keeps your continuation on a higher-priority track.

INP, not FID. Interaction to Next Paint replaced First Input Delay as a Core Web Vital in March 2024. FID only measured the first input — INP measures the worst interaction across the whole page lifetime (p98 for pages with 50+ interactions). If you're still tuning for FID's “first input only” measurement, you're optimizing the wrong tail.

Long task ≠ poor INP. A 60 ms script is a Long Task by definition, but if no input lands during it, INP is unaffected. INP's 200 ms threshold is about clicks colliding with work, not arbitrary work happening.

05 — Workers: A Second Thread

Splitting and deferring move work in time. Workers move work in space — onto a parallel thread that does not share the main call stack. On the right, the orange data-processor block flies up to a new lane labelled Worker and runs in parallel with vendor + route + render code. The main thread is nearly empty after the first half-second.

Spawning one is two lines:

TS
1const worker = new Worker(new URL("./csv-parser.ts", import.meta.url), {
2 type: "module",
3});
4worker.postMessage({ csv: bigPayload });
5worker.onmessage = (e) => render(e.data.rows);

postMessage deep-copies the payload through structured clone. A 1MB JSON costs a few ms. For megabyte buffers, transfer ownership of the underlying ArrayBuffer — the sender loses access, and the transfer is effectively free:

TS
1const bytes = new Uint8Array(8 * 1024 * 1024);
2worker.postMessage(bytes, [bytes.buffer]);

Workers can't touch the DOM. No document, no window, no localStorage. That sounds like a limitation; it's actually the architectural lever — workers force you to separate computation from rendering. The shape is always: main thread sends raw input worker parses, transforms, or encodes worker posts back a result main thread paints.

Use a worker for: CSV/JSON parsing of large payloads, image encode/decode, crypto, markdown rendering, sort/search over big lists, gzip/brotli decompression. Skip them for: anything under ~5 ms (the round-trip cost beats the saving) and anything that needs the DOM.

Partytown proxies third-party scripts (analytics, tag managers, A/B testing) onto a worker via a shim. Useful for code you don't control — not a substitute for a real worker for your own code.

06 — Sort Your Own Scripts

You've seen the pattern across five steps. Now the lab on the right hands it back to you: the seven scripts come pre-sorted from steps 25 (three sit on the Critical Path, the route chunks have already been deferred, one moved to a Worker), and the Live TTI counter at the top reflects every move you make. Re-classify what's left between the three zones:

  • Critical — needed before the user can interact with this route
  • Deferred — needed later, or never if the user never reaches that feature
  • Worker — pure computation, no DOM access

The TTI formula is unsubtle. Every script in Critical adds its parse + execute cost to TTI. Every script you defer subtracts it. Every script you move to Worker subtracts it from the main thread (the work still happens — just in parallel).

Try dragging dom-renderer.js onto the Worker zone in the lab. It bounces back with a red shake — workers cannot touch the DOM. That constraint isn't a list of rules; it's discovered by failure, the same way you'd discover it at runtime. The shape: workers run pure computation only.

Two subtle calls worth making once you're playing:

  • route-dashboard.js is not on this route. Deferring it makes TTI drop in the lab, but if a user lands on /dashboard directly, the page sits on a loading state until the chunk arrives. The TTI you see is this route's TTI/dashboard is a separate calculation. There is no free lunch, only a smaller bill per route.
  • Drop data-processor on the Critical zone and watch TTI jump by ~460 ms. The relationship is linear: every millisecond of script on critical is a millisecond of waiting for the user. Every drag = a metric change.

After a dozen drags, the intuition becomes hard to lose: the critical path is a budget you spend in milliseconds, and every line of code chooses whether to charge it.

SbRsDnYmWtSo
Step 1 — single 2 MB bundle on main thread
TTI2.10s
1 long task
0s0.5s1.0s1.5s2.0s2.5s
Main
vendor-react · 2100ms
TTI
Worker
spawn a worker to use this lane
One enormous block, capped with a long-task triangle. Until V8 finishes parse + compile + execute, the page is frozen.