../

JavaScript & the main thread

JavaScript is the most expensive byte on the web: it has to be downloaded, parsed, compiled and run on the main thread, the same thread that handles input and renders frames. This sheet covers shipping less of it, splitting what's left, keeping tasks short, and moving work off the main thread. The frame model behind all of this is on Browser rendering pipeline.

The cost of JavaScript

PhaseWhereScales withNotes
Downloadnetworkcompressed sizesame as an image of the same size
Decompress + parsemain thread / backgrounduncompressed sizeV8 parses and compiles lazily and off-thread for large scripts
Compilebackground + mainfunctions actually calledcached in the code cache on repeat visits
Executemain threadwork done at startupmodule top-level code, framework boot, hydration
  • 100 KB of JS costs far more than 100 KB of JPEG: the image only decodes (off-thread); the script runs.
  • Budget by uncompressed size and device class. A mid-range Android phone runs JS 3–5× slower than a recent laptop.
  • Check the Coverage tab in DevTools (⌘⇧P → "Coverage"): often more than half the JS loaded at startup never runs.

Bundle size

TechniqueHowWatch out
Code splitting by routethe framework does it (Next.js, Vite + router)shared chunks growing silently
Dynamic import()load a module when it's neededloading waterfalls: prefetch on hover/visibility
Tree shakingES modules + "sideEffects": false in package.jsonCommonJS and side-effectful imports defeat it
Import only what you useimport debounce from "lodash-es/debounce"barrel files (index.ts re-exporting everything) can pull in whole libraries
Replace heavy dependenciesdate-fns/Temporal over moment, native Intl, fetch over axioscheck with a bundle analyzer, not by feel
Don't ship polyfills to modern browserstarget modern syntax (es2022+), browserslista legacy target transpiles and polyfills everything
Minify + compressthe bundler minifies; the server compressessource maps: upload privately, don't serve publicly if the code is sensitive
Look inside the bundle
# Vite / Rollup
bunx vite-bundle-visualizer
# Next.js 16.1+ (Turbopack)
bunx next experimental-analyze
# Next.js with webpack: @next/bundle-analyzer
# any bundle + source map
bunx source-map-explorer dist/assets/*.js

Long tasks & yielding

A task that runs longer than 50 ms is a long task: input waits behind it and no frame can render. The fix is not "faster code" first; it's yielding: split the work so the browser can handle input and paint in between.

APIQueues the continuationSupport
await scheduler.yield()ahead of other tasks of the same priority (input and rendering can still run first)Chromium 129+, Firefox 142+; not Safari
scheduler.postTask(fn, { priority })a new task with "user-blocking", "user-visible" or "background" priorityChromium, Firefox 142+; not Safari
setTimeout(fn, 0)back of the queue (clamped to ≥ 4 ms after nesting)everywhere
requestIdleCallback(fn, { timeout })when the browser is idleChromium, Firefox; not stable Safari
requestAnimationFrame(fn)right before the next paint (not a yield: runs before rendering)everywhere
await new Promise(r => queueMicrotask(r))not a yield: microtasks run before rendering—
yield.ts: yieldToMain with a fallback
export type Priority =
  | "user-blocking"
  | "user-visible"
  | "background";
export interface SchedulerLike {
  yield?: () => Promise<void>;
  postTask?: <T>(
    fn: () => T,
    options?: { priority?: Priority },
  ) => Promise<T>;
}
export const scheduler = (
  globalThis as { scheduler?: SchedulerLike }
).scheduler;
 
export function yieldToMain(): Promise<void> {
  if (scheduler?.yield) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}
  • Yield after the work the user sees (update the button, then yield, then do the rest).
  • Yielding costs a few ms each time; yield every ~50 ms of work, not after every item.
  • navigator.scheduling.isInputPending() is deprecated; yield on a time budget instead.

Debounce, throttle & idle work

PatternFiresUse for
Debounceonce, after input stops for N mssearch-as-you-type, resize end, autosave
Throttleat most once per N msscroll position, pointer tracking, analytics
rAF throttleat most once per frameanything that updates the screen
Idle callbackwhen the main thread is freeprefetching, warming caches, non-urgent analytics
debounce.ts
export function debounce<A extends unknown[]>(
  fn: (...args: A) => void,
  ms: number,
): (...args: A) => void {
  let timer: ReturnType<typeof setTimeout> | undefined;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

Web Workers

Anything that doesn't touch the DOM can run on another thread: parsing large JSON, diffing, search indexing, image processing, crypto, syntax highlighting, WASM. The main thread only sends a message and renders the answer. Details: Web Workers.

Worth movingNot worth it
> 50 ms of pure computationtiny tasks (message overhead is ~0.1–1 ms plus copying)
work on large ArrayBuffers (transfer, don't copy)anything that needs the DOM or layout
repeated work during interaction (filtering 50k rows as you type)one-off startup code

OffscreenCanvas moves canvas drawing into a worker; Comlink makes worker calls look like async functions.

Third-party scripts

Tag managers, analytics, chat widgets, ads and A/B testing are often the biggest main-thread cost, and you don't control their code.

StrategyHow
AuditDevTools Performance → group by third party; Lighthouse "Reduce the impact of third-party code"
Load lateasync/defer; load after load or on idle; Next.js <Script strategy="lazyOnload">
Facadesa static placeholder that loads the real widget on click (YouTube, chat, maps)
Self-hostsmall, stable scripts on your origin: no extra connection, your cache headers
Server-sidesend analytics events from the server; server-side tag managers
Worker offload (Partytown)runs tags in a worker with a synchronous DOM proxy; compatibility varies, and it is not a security sandbox
Removethe fastest script is the one you delete; review tags every quarter

Security: a third-party script runs with your origin's full privileges. Pin it with SRI (integrity="sha384-…" crossorigin="anonymous") when the file is versioned, and allow its origins explicitly in your CSP script-src.

Hydration cost

Server-rendered HTML shows up fast, but it isn't interactive until the framework downloads its JS, runs components again, and attaches listeners: hydration. During hydration, taps wait (input delay).

ApproachEffect
Server Components (React)components that never hydrate ship no JS
Selective / progressive hydration (Suspense)hydrate the part the user touches first
Islands (Astro, Fresh)only interactive widgets hydrate; the page is static HTML
Resumability (Qwik)no hydration pass; handlers load on first interaction
Less client state at startupfewer, smaller "use client" boundaries, placed low in the tree

More in Rendering strategies.

Memory leaks

Leaks make long sessions slow (GC pauses) and eventually crash tabs on phones.

LeakFix
Event listeners on window/document never removedAbortController signal, remove on unmount
Timers and intervals left runningclear in cleanup
Detached DOM nodes kept in JS arrays/mapsdrop references; WeakMap/WeakRef for caches keyed by nodes
Unbounded caches and logscap size (LRU), or WeakMap
Closures capturing large objectsnarrow what the closure uses
Observers (ResizeObserver, IntersectionObserver) never disconnecteddisconnect() in cleanup

Find them in DevTools → Memory: take a heap snapshot, do the action a few times, take another, and compare; filter by "Detached" for detached DOM trees.

One signal removes every listener
const controller = new AbortController();
const { signal } = controller;
addEventListener("resize", onResize, { signal });
document.addEventListener("keydown", onKey, { signal });
// on unmount / navigation:
controller.abort();

Common mistakes

MistakeFix
One big bundle for every routeroute-level splitting; lazy-load below-the-fold widgets
import _ from "lodash"per-function ESM imports, or native methods
Barrel files re-exporting a whole libraryimport from the specific module; optimizePackageImports in Next.js
Processing 10k items in one loop on clickchunk and yield, or a worker
await Promise.resolve() as a "yield"it's a microtask; use scheduler.yield() / setTimeout
Every third-party tag in <head>, synchronousasync/defer, after load, or facades
Heavy JSON parsing on the main threadstream it, paginate it, or parse in a worker
Testing on a flagship phone4–6× CPU throttling in DevTools, or a real mid-range device

Recipes

Process a big list without blocking input

Work in ~50 ms slices, yielding between them so clicks and frames get through.

chunked.ts
import { yieldToMain } from "./yield";
 
export async function processAll<T>(
  items: readonly T[],
  work: (item: T) => void,
  sliceMs = 50,
): Promise<void> {
  let deadline = performance.now() + sliceMs;
  for (const item of items) {
    work(item);
    if (performance.now() >= deadline) {
      await yieldToMain();
      deadline = performance.now() + sliceMs;
    }
  }
}

Load a heavy module on interaction

The editor's code downloads only when the user asks for it; hovering starts it early.

lazy-editor.ts
const button = document.querySelector("#edit")!;
let editor: Promise<typeof import("./editor")> | null =
  null;
const load = () =>
  (editor ??= import("./editor").catch((err) => {
    editor = null; // let the next click retry
    throw err;
  }));
 
button.addEventListener("pointerenter", load, {
  once: true,
});
button.addEventListener("click", async () => {
  const { openEditor } = await load();
  openEditor();
});

Load a widget when it scrolls into view

Nothing below the fold loads until it's about to be seen.

on-visible.ts
export function onVisible(
  el: Element,
  run: () => void,
  rootMargin = "200px",
): void {
  const io = new IntersectionObserver((entries) => {
    if (entries.some((e) => e.isIntersecting)) {
      io.disconnect();
      run();
    }
  }, { rootMargin });
  io.observe(el);
}
 
onVisible(document.querySelector("#comments")!, () =>
  import("./comments").then((m) => m.mount()));

Background work with priorities

postTask with a fallback: urgent work first, analytics last.

post-task.ts
import { scheduler, type Priority } from "./yield";
 
export function postTask<T>(
  fn: () => T,
  priority: Priority,
): Promise<T> {
  if (scheduler?.postTask) {
    return scheduler.postTask(fn, { priority });
  }
  const delay = priority === "background" ? 100 : 0;
  return new Promise((resolve, reject) =>
    setTimeout(() => {
      try {
        resolve(fn());
      } catch (err) {
        reject(err);
      }
    }, delay));
}
 
postTask(() => sendAnalytics(), "background");

A chat widget facade

Show a static button; load the real (heavy) widget only when someone clicks it.

chat-facade.html
<button id="chat" type="button">Chat with us</button>
<script type="module">
  const btn = document.querySelector("#chat");
  btn.addEventListener("click", async function open() {
    btn.disabled = true;
    try {
      const { openChat } = await import("/chat-widget.js");
      openChat();
      btn.removeEventListener("click", open);
    } catch {
      btn.disabled = false; // retry on the next click
    }
  });
</script>

References