Breaking Up Long Tasks on Static Sites

Interaction to Next Paint measures how quickly a page responds to clicks, taps and key presses, and it is the Core Web Vital that static sites fail in the most surprising way. The HTML is static, the CSS is small, nothing is server-rendered on demand — and yet a reader taps the menu button and nothing happens for 300 milliseconds. The cause is almost always a long task: a single block of JavaScript running for more than 50 ms on the main thread, during which the browser cannot respond to input. On static sites those tasks come from the small amount of JavaScript the site does run — hydration, search, analytics, handlers that do too much at once.

This guide finds the long tasks on a static documentation site with field attribution and DevTools, breaks them up with scheduler.yield(), idle callbacks and a web worker, and measures the effect on field INP. It is part of JavaScript Hydration & Partial Rendering, and builds on Measuring INP on Static Sites with Real User Monitoring.

Prerequisites

  • Field INP data with attribution (the web-vitals attribution build, or CrUX for a coarse view).
  • Chrome DevTools Performance panel.
  • Control over the site's JavaScript, including third-party loading.

Step 1: Find Which Interactions Are Slow

INP attribution from the web-vitals library reports, for the slowest interaction on a page, the target element, the interaction type, and the breakdown into input delay, processing duration and presentation delay:

import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution: a }) => beacon({
  metric: 'INP', value, target: a.interactionTarget, type: a.interactionType,
  inputDelay: a.inputDelay, processing: a.processingDuration, presentation: a.presentationDelay,
  longestScript: a.longAnimationFrameEntries?.[0]?.scripts?.[0]?.sourceURL
}));

The longAnimationFrameEntries field (from the Long Animation Frames API) names the scripts that ran during the slow frame — the most useful single field for static sites, because it points straight at the culprit file. A week of data on the documentation site showed an INP p75 of 280 ms on mobile, and three patterns:

Slow interactionMain componentScript responsible
First tap anywhere within 3 s of loadinput delay (~220 ms)tag manager + search index init
Typing in the search boxprocessing (~180 ms per key)search result rendering
Opening a code tab groupprocessing (~140 ms)tab component re-highlighting all panels
INP breakdown for the three slow interaction patterns Horizontal bars of median time for the slowest part of each slow interaction. First tap after load spent 220 milliseconds in input delay behind tag manager and search index initialisation. Typing in search spent 180 milliseconds processing per keystroke. Opening a code tab group spent 140 milliseconds processing because all panels were re-highlighted. Where slow interactions spent their time First tap after load (input delay) 220 ms Typing in search (processing) 180 ms Opening code tabs (processing) 140 ms
Median from one week of web-vitals attribution on mobile, interactions over 200 ms.

Step 2: Confirm in DevTools

Recording a throttled load in the Performance panel (4× CPU slowdown, Fast 4G) and tapping during the first seconds showed the long tasks directly: a 190 ms task evaluating the tag manager container, followed by a 160 ms task building the search index from a 1.2 MB JSON file — both started on DOMContentLoaded, both blocking any tap that arrived during them. Typing in the search box produced a 150–200 ms task per keystroke, rendering up to 200 results into the DOM. The tabs component ran Prism over every panel whenever any tab was clicked.

Fix 1: Defer Non-Urgent Startup Work

Nothing about the tag manager or the search index needs to happen in the first seconds. The search index now initialises on first focus of the search box (with a warm-up on pointer hover over the search button), and the tag manager loads after the page is idle and after first input:

function whenIdle(fn) {
  ('requestIdleCallback' in window) ? requestIdleCallback(fn, { timeout: 4000 }) : setTimeout(fn, 2000);
}
addEventListener('load', () => whenIdle(loadTagManager), { once: true });
searchInput.addEventListener('focus', () => import('./search.js').then((m) => m.init()), { once: true });

The first-tap input delay dropped from 220 ms to 30 ms. Tag manager specifics, including moving it to Partytown, are in Loading Google Tag Manager Without Hurting INP.

Fix 2: Yield Inside Long Loops

Search rendering built all results in one loop. Rendering the first ten, yielding, then rendering the rest lets the browser paint the first results and handle the next keystroke:

const yieldToMain = () => ('scheduler' in window && 'yield' in scheduler)
  ? scheduler.yield()
  : new Promise((r) => setTimeout(r, 0));

async function renderResults(results, list, token) {
  list.replaceChildren();
  for (let i = 0; i < results.length; i++) {
    list.append(resultItem(results[i]));
    if (i % 10 === 9) {
      await yieldToMain();
      if (token.cancelled) return;          // a newer keystroke started a new render
    }
  }
}

The cancellation token matters as much as the yield: without it, a render for the query "cach" keeps running after the reader typed "cache", wasting the time the yield freed. Processing time per keystroke fell from 180 ms to 35 ms at p75, because the first chunk was small and later chunks ran between keystrokes or were cancelled.

Search rendering as one long task and as yielded chunks Two panels. As one task, rendering 200 results blocked the main thread for 180 milliseconds and a keystroke arriving during it waited for the whole task. As yielded chunks, each chunk of 10 results took about 9 milliseconds, the browser painted the first results after the first chunk, handled the next keystroke between chunks, and cancelled the outdated render. One long task versus yielded chunks One 180 ms task renders all 200 results at once keystrokes wait for the task first paint of results at the end processing p75 180 ms Yielded chunks of 10 about 9 ms per chunk first 10 results paint immediately outdated renders cancelled processing p75 35 ms
scheduler.yield in Chromium, setTimeout fallback elsewhere; chunk size of 10 chosen from profiling.

Fix 3: Move Pure Work to a Worker

Searching the index itself — tokenizing the query and scoring thousands of entries — took 40–70 ms per keystroke on a mid-range phone. It touches no DOM, so it moved to a web worker, with the main thread only sending the query and rendering results:

// main thread
const worker = new Worker(new URL('./search-worker.js', import.meta.url), { type: 'module' });
input.addEventListener('input', () => worker.postMessage({ q: input.value, id: ++lastId }));
worker.onmessage = ({ data }) => { if (data.id === lastId) renderResults(data.results, list, newToken()); };

On sites using Pagefind, search already runs mostly off the main thread in WebAssembly, and this step is unnecessary; this site used a custom Lunr index, where the move to a worker took scoring off the main thread entirely.

Fix 4: Do Less on Interaction

The code tabs component re-highlighted every panel with Prism whenever any tab was clicked — a leftover from an early version that highlighted lazily. Highlighting moved to build time with Shiki, as in Syntax Highlighting with Shiki in Astro and Eleventy, and the click handler now only toggles hidden attributes. Processing for a tab click dropped from 140 ms to 4 ms. The general lesson: the best way to shorten a task that runs on interaction is to move its work to build time.

Field INP after each long-task fix A timeline of field INP at the 75th percentile on mobile. Baseline 280 milliseconds. After deferring the tag manager and search index, 190 milliseconds. After yielding in search rendering, 150 milliseconds. After moving search scoring to a worker, 130 milliseconds. After moving highlighting to build time, 110 milliseconds. INP p75 on mobile as fixes shipped Baseline Deferred startup work Yield in search render Search in a worker Build-time highlighting 280 ms 190 ms 150 ms 130 ms 110 ms
Each point is a seven-day window of web-vitals RUM data after the change shipped.

Keeping Tasks Short

Two guards prevent regressions. A Playwright test on the main templates records long tasks with a PerformanceObserver for longtask entries under 4× CPU throttling, and fails if any task after load exceeds 100 ms. And the field dashboard tracks INP attribution by script URL weekly, so a new third-party script or a heavy handler shows up by name, not just as a worse number.

Measured Impact

MeasureBeforeAfter
INP p75, mobile (field)280 ms110 ms
INP p75, desktop (field)120 ms56 ms
Long tasks during load (lab, 4× CPU)5, longest 190 ms1, 62 ms
Search keystroke processing p75180 ms35 ms
Tab click processing140 ms4 ms
Total Blocking Time (lab)520 ms90 ms

Pitfalls & Rollback

  • Yielding without cancellation. Outdated work keeps running; cancel it when newer input arrives.
  • Deferring work the first interaction needs. Warm up on hover or focus so the first use is not slow.
  • Moving DOM work to a worker. Workers cannot touch the DOM; keep rendering on the main thread in small chunks.
  • Trusting lab TBT alone. Field INP attribution shows which interactions readers actually perform.
  • Rollback: each fix is isolated to one module; reverting one does not affect the others.

Conclusion

Static sites fail INP because of the little JavaScript they run, not because of their HTML. Find the slow interactions with field attribution and Long Animation Frame data, defer startup work until it is needed, yield inside long loops with cancellation, move pure computation to a worker, and move anything possible to build time. On this site that took mobile INP from 280 ms to 110 ms.

FAQ

What is a long task?

Any single piece of main-thread work that runs for more than 50 milliseconds. While it runs, the browser cannot respond to clicks, taps or key presses, so an interaction that happens during a long task waits for it to finish, which shows up as poor Interaction to Next Paint.

Why would a static site have long tasks?

Static HTML itself does not, but the JavaScript added to it can. Hydration of framework islands, search index initialisation, syntax highlighting in the browser, analytics and tag managers, and event handlers that do heavy work synchronously are the usual sources.

What does scheduler.yield do?

It pauses the current task and lets the browser handle pending input and rendering before continuing, while keeping the continuation at a higher priority than other queued tasks. It is available in Chromium browsers; a fallback using setTimeout works elsewhere.

Should I just move everything to a web worker?

Work that does not touch the DOM, such as searching an index, parsing data or computing diffs, moves well to a worker. DOM updates must stay on the main thread, so the pattern is usually to compute in a worker and apply small DOM changes on the main thread.

Filed underHow-tos