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.
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.
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.
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.
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:
requestIdleCallbacksupported;scheduler.yieldsupport varies by version — use thesetTimeoutfallback. - Safari:
requestIdleCallbackis not available in all versions; fall back tosetTimeout-based chunking.
Verification
- Record a load of a large fixture page with 4× CPU throttling and confirm no long tasks from
chrome-extension://scripts. - Add 500 nodes dynamically and confirm the observer callback stays under a millisecond.
- Scroll during annotation and confirm frames are not dropped.
- 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.
Related
- Measuring content script impact on page load — measurement.
- Throttling and debouncing high-frequency events — event volume.
- Observing DOM changes efficiently with MutationObserver — observer patterns.
- Performance profiling and optimisation — the parent topic.