Avoiding Long Tasks in Content Scripts

Keep content scripts from janking the pages they run on: finding long tasks with PerformanceObserver and the Performance panel, chunking DOM work with scheduler.yield, batching reads and writes, idle-time processing, and moving computation off the main thread.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The extension scans every page for prices and annotates them. On a long product list, the scan takes 280 milliseconds in one go — and for those 280 milliseconds the page cannot respond: clicks are ignored, scrolling stutters, and the site’s Interaction to Next Paint score, which the site owner monitors, gets worse. Content scripts share the page’s main thread; every millisecond they spend is a millisecond the page cannot use. A “long task” — anything over 50 milliseconds — is where users feel it. This guide finds long tasks caused by your content script and breaks them up so the page stays responsive. It belongs to performance profiling and optimisation.

Why content scripts cause jank

Content scripts run in an isolated JavaScript world, but on the same main thread as the page’s own scripts, layout and rendering. A synchronous loop over thousands of DOM nodes, a regular expression over the full page text, a MutationObserver callback that does heavy work per mutation, or interleaved DOM reads and writes that force repeated layout — each can block the thread for tens or hundreds of milliseconds. The browser cannot process input or paint until the task ends. The fix is to do less work, do it in small chunks that yield to the browser between them, avoid forced layouts, and move pure computation to a worker.

One long task versus yielding chunksA single 280 ms scan blocks input and paint; the same work split into 5 ms chunks with scheduler.yield between them lets the browser handle clicks and render frames in between, keeping the page responsive.Scan 280 msone taskInput blockedclick waitsDropped framesscroll janksplit + yieldChunk ≤ 5 msprocess batchyieldinput + paint runNext chunkuntil done
Same total work, but the page stays interactive.

Step-by-step: responsive content scripts

1. Find long tasks you cause

1// content script — development builds
2new PerformanceObserver((list) => {
3  for (const e of list.getEntries()) {
4    const ours = e.scripts?.some((s) => s.sourceURL?.startsWith(chrome.runtime.getURL("")));
5    if (ours) console.warn(`[readable] long animation frame ${Math.round(e.duration)} ms`, e.scripts.map((s) => s.invoker));
6  }
7}).observe({ type: "long-animation-frame", buffered: true });

Execution context: a content script in development. The Long Animation Frames API (Chrome 123+) attributes slow frames to the scripts that ran in them, including their source URL, so you can tell your content script’s work apart from the page’s. In the Performance panel, record a page load or interaction and look for red-cornered tasks; the bottom-up view shows functions from chrome-extension:// URLs. See measuring content script impact on page load.

2. Split work into chunks that yield

 1const yieldToMain = () =>
 2  globalThis.scheduler?.yield ? scheduler.yield() : new Promise((r) => setTimeout(r, 0));
 3
 4async function annotateAll(nodes) {
 5  let deadline = performance.now() + 5;
 6  for (const node of nodes) {
 7    annotate(node);
 8    if (performance.now() >= deadline) {
 9      await yieldToMain();
10      deadline = performance.now() + 5;
11    }
12  }
13}

Execution context: a content script. Processing until a time budget (here 5 ms) and then yielding keeps each task short regardless of how fast the device is. scheduler.yield() (Chrome 129+) resumes your work with priority ahead of other queued tasks, so total time does not balloon; setTimeout(0) is the fallback. The page can process input and paint between chunks.

Causes of long tasks in content scriptsCommon content script work that creates long tasks — full-page scans, layout thrashing, heavy MutationObserver callbacks, large regex or parsing, synchronous storage of big objects — with the fix for each.WorkTypical costFixScan every node at load100–500 ms on big pagesChunk + yield; scope with selectorsRead/write layout in a loopForced reflow per itemBatch reads, then writesHeavy MutationObserver callbackRuns on every changeQueue + process in idle chunksRegex over full textLarge stringsPer-node, or in a workerJSON of large dataParse/stringify blocksSmaller payloads; worker
Most fixes are: do less, batch, yield, or move off-thread.

3. Avoid layout thrashing

1// Slow: read, write, read, write … forces layout each time
2for (const el of prices) { const r = el.getBoundingClientRect(); badgeFor(el).style.top = `${r.top}px`; }
3
4// Fast: all reads, then all writes (in one frame)
5const rects = prices.map((el) => el.getBoundingClientRect());
6requestAnimationFrame(() => prices.forEach((el, i) => { badgeFor(el).style.top = `${rects[i].top}px`; }));

Execution context: a content script. Reading layout (getBoundingClientRect, offsetHeight) after writing styles forces the browser to recompute layout synchronously. Doing all reads first, then all writes in a requestAnimationFrame, computes layout once. On a page with hundreds of annotations this can turn hundreds of milliseconds into a few. Better still, use CSS positioning (anchoring to the element) so no measurement is needed.

Processing mutations without blockingThe page adds 200 product cards; the MutationObserver callback only queues the added nodes and schedules processing; an idle callback processes the queue in small chunks, yielding between them; the user's scroll and clicks stay smooth.PageObserver callbackIdle processor200 nodes addedqueue.push(...) — < 1 msrequestIdleCallbackchunks of 5 ms …scroll stays smooth
Observe cheaply, process later in small pieces.

4. Keep MutationObserver callbacks tiny

 1const queue = new Set();
 2let scheduled = false;
 3const observer = new MutationObserver((records) => {
 4  for (const r of records) for (const n of r.addedNodes) if (n.nodeType === 1) queue.add(n);
 5  if (!scheduled) { scheduled = true; requestIdleCallback(drain, { timeout: 500 }); }
 6});
 7
 8async function drain() {
 9  scheduled = false;
10  const nodes = [...queue]; queue.clear();
11  await annotateAll(nodes.flatMap((n) => [...n.querySelectorAll?.(".price") ?? []]));
12}
13observer.observe(document.body, { childList: true, subtree: true });

Execution context: a content script. The callback only collects nodes; real work happens later in idle time, chunked. The timeout ensures processing happens within half a second even on busy pages. Deduplicating with a Set avoids processing the same subtree twice when a site re-renders rapidly. See observing DOM changes efficiently with MutationObserver.

5. Prioritise what the user can see

1const io = new IntersectionObserver((entries) => {
2  for (const e of entries) if (e.isIntersecting) { annotate(e.target); io.unobserve(e.target); }
3}, { rootMargin: "200px" });
4document.querySelectorAll(".price").forEach((el) => io.observe(el));

Execution context: a content script. Annotating only elements near the viewport spreads work across scrolling and skips content the user never reaches. For a page with thousands of items, this alone can remove the long task entirely.

6. Move pure computation to a worker

Parsing, scoring or matching that doesn’t need the DOM can run in a Web Worker. Content scripts cannot construct workers from extension URLs in every browser, so a common pattern is to send the data to the service worker or an offscreen document and receive results asynchronously. Keep payloads small — the messaging cost must be lower than the computation saved. See running Wasm and heavy compute outside the service worker.

7. Start later and do less at load

Use run_at: "document_idle" unless you must act earlier, check settings before doing any work (a paused site should cost almost nothing), and scope selectors to the parts of the page you care about rather than scanning the whole document.

Common mistakes

  • One big loop at load. Long task on every large page.
  • Interleaved layout reads and writes. Forced reflows multiply cost.
  • Heavy work inside MutationObserver callbacks. Runs on every DOM change.
  • Processing off-screen content eagerly. Wasted work.
  • Assuming fast devices. Test with CPU throttling at 4×.

Cross-browser variation

  • Chrome / Edge: scheduler.yield (129+), Long Animation Frames API (123+), requestIdleCallback.
  • Firefox: requestIdleCallback supported; scheduler.yield support varies by version — use the setTimeout fallback.
  • Safari: requestIdleCallback is not available in all versions; fall back to setTimeout-based chunking.

Verification

  1. Record a load of a large fixture page with 4× CPU throttling and confirm no long tasks from chrome-extension:// scripts.
  2. Add 500 nodes dynamically and confirm the observer callback stays under a millisecond.
  3. Scroll during annotation and confirm frames are not dropped.
  4. Compare the page’s INP with and without the extension.

FAQ

Is 50 ms the right budget?

It is the threshold for a long task. Aim for chunks well under it — 5–10 ms — so the page’s own work fits too.

Does the isolated world run on a separate thread?

No. It has a separate JavaScript global, but shares the page’s main thread.

Should I use requestIdleCallback or scheduler.yield?

Use idle callbacks to start deferred work and scheduler.yield (or a fallback) to break up work once started.

Will chunking make the total slower?

Slightly, because of yielding overhead — but the page stays responsive, which is what users notice. scheduler.yield keeps the overhead small.

Other Testing, Debugging & Performance Optimization Resources