# Keep Long Tasks Off the Main Thread

**MUST** · **ID:** `performance-offload-main-thread` · **Category:** performance
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/performance-offload-main-thread

> MUST: Move expensive computation off the main thread — anything past ~50ms is a long task by Core Web Vitals and eats INP; an 800ms sync loop paints no frames and queues every click, while the CSS spinner keeps turning on the compositor and disguises the freeze as progress. Post it to a Web Worker, or chunk it and yield with `scheduler.yield()` if it must touch the DOM.

Move long-running computation into a Web Worker so the page stays interactive

The main thread renders every frame, runs every event handler, and executes your JavaScript — one queue, one worker. A synchronous computation that runs for 800ms does not make the page slow, it makes it dead: no frames paint, clicks and keystrokes sit in the queue, and a CSS spinner is the only thing still moving (because it is on the compositor) which makes the freeze look like a working loading state. Anything past roughly 50ms is already a "long task" by Core Web Vitals' definition and eats directly into INP. Post the work to a Web Worker — it runs on its own thread and messages the result back — or, if it genuinely must touch the DOM, chunk it and yield with scheduler.yield() between slices.

## Rule snippet

```tsx
const worker = new Worker(new URL("./crunch.worker.ts", import.meta.url), { type: "module" });
worker.postMessage(rows);
worker.onmessage = (e) => setResult(e.data);
```

## Bad — do not do this

`performance-offload-main-thread-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

const LIMIT = 1_500_000;

function countPrimes(limit: number) {
  let count = 0;
  for (let n = 2; n < limit; n++) {
    let prime = true;
    for (let d = 2; d * d <= n; d++) {
      if (n % d === 0) {
        prime = false;
        break;
      }
    }
    if (prime) count++;
  }
  return count;
}

export function OffloadMainThreadBad() {
  const [text, setText] = useState('');
  const [result, setResult] = useState<{ primes: number; ms: number; worst: number } | null>(null);
  const dialRef = useRef<HTMLDivElement>(null);
  const frameRef = useRef<HTMLDivElement>(null);
  const worst = useRef(0);

  // A JS-driven ticker: it can only advance when the main thread is free.
  useEffect(() => {
    let raf = 0;
    let last = performance.now();
    const tick = (t: number) => {
      const dt = t - last;
      last = t;
      if (dt > worst.current) worst.current = dt;
      if (dialRef.current) dialRef.current.style.transform = `rotate(${(t / 4) % 360}deg)`;
      if (frameRef.current) {
        frameRef.current.textContent = `frame: ${dt.toFixed(1)} ms · worst: ${worst.current.toFixed(0)} ms`;
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, []);

  const run = () => {
    worst.current = 0;
    const t0 = performance.now();
    // Blocks the main thread: no frames, no keystrokes, no clicks until this returns.
    const primes = countPrimes(LIMIT);
    const ms = performance.now() - t0;
    setResult({ primes, ms: Math.round(ms), worst: 0 });
    window.setTimeout(
      () => setResult({ primes, ms: Math.round(ms), worst: Math.round(worst.current) }),
      300,
    );
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="space-y-3 rounded-lg border border-border bg-card p-4">
        <div className="flex items-center gap-3">
          <div className="grid size-10 place-items-center rounded-full border border-border bg-muted">
            <div ref={dialRef} className="h-4 w-0.5 origin-bottom rounded-full bg-primary" />
          </div>
          <div ref={frameRef} className="text-xs tabular-nums text-muted-foreground">
            frame: — · worst: —
          </div>
        </div>

        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="Type here while it computes…"
          className="w-full rounded-md border border-border bg-muted px-3 py-2 text-sm text-foreground"
        />

        <button
          type="button"
          onClick={run}
          className="w-full rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground"
        >
          Count primes below {LIMIT.toLocaleString('en-US')}
        </button>

        {result && (
          <div className="space-y-1 text-xs tabular-nums text-error">
            <div>
              {result.primes.toLocaleString('en-US')} primes in {result.ms} ms
            </div>
            <div>longest frozen frame: {result.worst || '…'} ms</div>
          </div>
        )}
      </div>
      <p className="text-xs text-error">
        Start typing, then hit the button. The needle stops dead, your keystrokes queue up, and the
        longest frame stretches to roughly the length of the whole computation — one task, one
        thread, everything else waits. That is a frozen tab, not a slow one.
      </p>
    </div>
  );
}
```

## Good — do this

`performance-offload-main-thread-good`

```tsx
import { useEffect, useRef, useState } from 'react';

const LIMIT = 1_500_000;

// Same trial-division loop as the bad example — just not on the thread that renders.
const WORKER_SRC = `
self.onmessage = (e) => {
  const limit = e.data;
  let count = 0;
  for (let n = 2; n < limit; n++) {
    let prime = true;
    for (let d = 2; d * d <= n; d++) {
      if (n % d === 0) { prime = false; break; }
    }
    if (prime) count++;
  }
  self.postMessage(count);
};`;

export function OffloadMainThreadGood() {
  const [text, setText] = useState('');
  const [busy, setBusy] = useState(false);
  const [result, setResult] = useState<{ primes: number; ms: number; worst: number } | null>(null);
  const dialRef = useRef<HTMLDivElement>(null);
  const frameRef = useRef<HTMLDivElement>(null);
  const workerRef = useRef<Worker | null>(null);
  const worst = useRef(0);
  const started = useRef(0);

  useEffect(() => {
    const url = URL.createObjectURL(new Blob([WORKER_SRC], { type: 'text/javascript' }));
    const worker = new Worker(url);
    workerRef.current = worker;
    worker.onmessage = (e: MessageEvent<number>) => {
      setResult({
        primes: e.data,
        ms: Math.round(performance.now() - started.current),
        worst: Math.round(worst.current),
      });
      setBusy(false);
    };
    return () => {
      worker.terminate();
      URL.revokeObjectURL(url);
    };
  }, []);

  useEffect(() => {
    let raf = 0;
    let last = performance.now();
    const tick = (t: number) => {
      const dt = t - last;
      last = t;
      if (dt > worst.current) worst.current = dt;
      if (dialRef.current) dialRef.current.style.transform = `rotate(${(t / 4) % 360}deg)`;
      if (frameRef.current) {
        frameRef.current.textContent = `frame: ${dt.toFixed(1)} ms · worst: ${worst.current.toFixed(0)} ms`;
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, []);

  const run = () => {
    if (busy) return;
    worst.current = 0;
    started.current = performance.now();
    setBusy(true);
    workerRef.current?.postMessage(LIMIT);
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="space-y-3 rounded-lg border border-border bg-card p-4">
        <div className="flex items-center gap-3">
          <div className="grid size-10 place-items-center rounded-full border border-border bg-muted">
            <div ref={dialRef} className="h-4 w-0.5 origin-bottom rounded-full bg-primary" />
          </div>
          <div ref={frameRef} className="text-xs tabular-nums text-muted-foreground">
            frame: — · worst: —
          </div>
        </div>

        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="Type here while it computes…"
          className="w-full rounded-md border border-border bg-muted px-3 py-2 text-sm text-foreground"
        />

        <button
          type="button"
          onClick={run}
          className="w-full rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:opacity-60"
          disabled={busy}
        >
          {busy ? 'Working in a Web Worker…' : `Count primes below ${LIMIT.toLocaleString('en-US')}`}
        </button>

        {result && (
          <div className="space-y-1 text-xs tabular-nums text-success">
            <div>
              {result.primes.toLocaleString('en-US')} primes in {result.ms} ms
            </div>
            <div>longest frame on the main thread: {result.worst} ms</div>
          </div>
        )}
      </div>
      <p className="text-xs text-success">
        Identical loop, posted to a Web Worker. The computation takes about as long as before, but it
        runs off the render thread: the needle keeps sweeping, the input keeps accepting keystrokes,
        and the longest main-thread frame stays a fraction of what the blocking version reports.
        Compare the two &ldquo;worst frame&rdquo; numbers on the same machine.
      </p>
    </div>
  );
}
```

## References

- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [MDN: Using Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers)
- [web.dev: Off the main thread](https://web.dev/articles/off-main-thread)
