Measuring Content Script Impact on Page Load
Quantify what your content script costs the pages it runs on: run_at trade-offs, long-task attribution, layout thrash from injected DOM, and a repeatable A/B measurement.
Table of Contents
- Root cause: your script competes with the page for one main thread
- Step 1. Measure your own script from the inside
- Step 2. Attribute long tasks to yourself
- Step 3. Run a controlled A/B against real pages
- Step 4. Find the three costs that dominate
- Step 5. Move work off the critical path
- Cross-browser variation
- Verification
- FAQ
- Related
A one-star review says your extension “makes everything slow”. You open a page, it feels fine, and there is nothing in the console. The cost is real but invisible from the inside: your content script runs on every page the user visits, and eighty milliseconds of work on a site that was already struggling is the difference between a page that feels instant and one that does not. This page is part of Performance Profiling & Optimization for MV3 Extensions and makes that cost a number you can watch.
Root cause: your script competes with the page for one main thread
A content script runs in an isolated world but on the page’s main thread. Every millisecond it spends parsing, querying the DOM or reading layout is a millisecond the page cannot spend rendering. run_at decides when you take that time, and the three options have very different consequences.
Step 1. Measure your own script from the inside
Before profiling anything, make the script report its own cost. A single mark pair is enough to see whether you are in the microsecond or the tens-of-milliseconds range.
1// content.js — the first and last lines of the script
2performance.mark("ext:start");
3
4await bootstrap();
5
6performance.mark("ext:end");
7performance.measure("ext:bootstrap", "ext:start", "ext:end");
8
9const [entry] = performance.getEntriesByName("ext:bootstrap");
10if (entry.duration > 16) {
11 chrome.runtime.sendMessage({ type: "PERF", metric: "bootstrap", ms: Math.round(entry.duration) });
12}
Execution context: Content script, isolated world, on the page’s main thread. performance.mark writes into the page’s own performance timeline, so the entries are visible in the Performance panel alongside the page’s — which is the point, because it lets you see your work next to the page’s layout and paint. The sixteen-millisecond threshold is one frame: anything longer than that is guaranteed to have cost the page a frame if it happened during rendering. Reporting only over the threshold keeps the message volume near zero on healthy pages.
Step 2. Attribute long tasks to yourself
PerformanceObserver reports long tasks, but not who caused them. Bracketing your own work is what turns “something blocked for 90 ms” into “we blocked for 90 ms”.
1// content.js
2let inOurCode = false;
3
4new PerformanceObserver((list) => {
5 for (const task of list.getEntries()) {
6 if (!inOurCode) continue; // the page's own long task, not ours
7 chrome.runtime.sendMessage({
8 type: "PERF", metric: "long-task", ms: Math.round(task.duration), at: location.hostname,
9 });
10 }
11}).observe({ type: "longtask", buffered: true });
12
13export async function guarded(label, work) {
14 inOurCode = true;
15 try { return await work(); } finally { inOurCode = false; }
16}
Execution context: Content script. The flag is a coarse attribution and deliberately so — precise attribution needs PerformanceObserver with type: "long-animation-frame", which is Chromium-only. Sending the hostname rather than the full URL keeps the telemetry from becoming a browsing history, which matters both ethically and for what you can defend at review; the reasoning is the same as in writing a permission justification that passes. Firefox supports longtask from version 121; Safari does not implement it, so treat its absence as no data rather than no problem.
Step 3. Run a controlled A/B against real pages
Self-reported numbers tell you what your script costs. They do not tell you what the page costs with and without it, which is what the review was about.
1// bench/measure.mjs — Playwright, same pages, extension on and off
2import { chromium } from "playwright";
3
4const URLS = ["https://example.com/", "https://news.ycombinator.com/", "https://en.wikipedia.org/wiki/Browser_extension"];
5
6async function measure({ withExtension }) {
7 const args = withExtension
8 ? [`--disable-extensions-except=${process.env.EXT_DIR}`, `--load-extension=${process.env.EXT_DIR}`]
9 : [];
10 const context = await chromium.launchPersistentContext("", { headless: false, args });
11
12 const rows = [];
13 for (const url of URLS) {
14 const page = await context.newPage();
15 await page.goto(url, { waitUntil: "load" });
16 rows.push(await page.evaluate(() => {
17 const nav = performance.getEntriesByType("navigation")[0];
18 const lcp = performance.getEntriesByType("largest-contentful-paint").at(-1);
19 return { domInteractive: nav.domInteractive, loadEnd: nav.loadEventEnd, lcp: lcp?.startTime ?? null };
20 }));
21 await page.close();
22 }
23
24 await context.close();
25 return rows;
26}
27
28const off = await measure({ withExtension: false });
29const on = await measure({ withExtension: true });
30console.table(URLS.map((url, i) => ({
31 url,
32 domInteractiveDelta: Math.round(on[i].domInteractive - off[i].domInteractive),
33 lcpDelta: on[i].lcp && off[i].lcp ? Math.round(on[i].lcp - off[i].lcp) : null,
34})));
Execution context: Node with Playwright, driving a headed Chromium — extensions do not load in the old headless mode, so headless: false (or the new headless mode) is required. Running the same URLs in both configurations in one script is what makes the comparison meaningful; comparing today’s numbers against a note from last month measures the network, not your code. Run each configuration three times and take the median, because a single load is dominated by cache state and network variance. The harness setup is covered in loading an unpacked extension in Playwright.
Step 4. Find the three costs that dominate
Forced synchronous layout is the one to look for first. Reading a geometry property after any DOM write forces the browser to lay out immediately, and doing it inside a loop over elements makes the cost quadratic.
1// Before: reads and writes interleaved — one forced layout per element.
2for (const el of items) {
3 el.classList.add("ext-marked");
4 el.dataset.height = String(el.offsetHeight); // forces layout, every iteration
5}
6
7// After: batch the reads, then batch the writes.
8const heights = items.map((el) => el.offsetHeight); // one layout for the whole set
9for (const [i, el] of items.entries()) {
10 el.classList.add("ext-marked");
11 el.dataset.height = String(heights[i]);
12}
Execution context: Content script on the page’s main thread. The rewrite changes nothing about what the code does and removes almost all of its cost on a page with a few hundred matches. The Performance panel labels the original pattern as “Layout” entries with a purple warning triangle and “Forced reflow” in the summary — that label is the fastest way to find it in a recording, and it behaves identically in Chrome, Firefox and Safari because it is a rendering-engine property rather than an extension one.
Step 5. Move work off the critical path
Once the work is measured, most of it turns out not to need to happen during load at all.
1// content.js
2const idle = window.requestIdleCallback ?? ((fn) => setTimeout(fn, 200));
3
4// Only the part the user can see immediately runs now.
5applyCriticalStyles();
6
7idle(() => {
8 scanDocument(); // the expensive pass
9 observeMutations(); // and only then start watching for more
10}, { timeout: 2000 });
Execution context: Content script. requestIdleCallback is not implemented in Safari, hence the timeout fallback — a fixed delay is a worse scheduler but an acceptable one for work that was never urgent. Deferring the MutationObserver registration as well as the scan is the part people miss: an observer attached during load fires throughout the page’s own construction, turning one expensive pass into dozens. Where the work genuinely does not need the DOM at all, move it to the service worker entirely and message the result back.
Cross-browser variation
- Chrome/Edge:
longtaskandlong-animation-frameobservers available; the Performance panel attributes work to the extension’s isolated world under its own entry.document_idleis scheduled afterloador a timeout, whichever comes first. - Firefox:
longtaskfrom version 121; nolong-animation-frame. The profiler shows content-script frames under the page’s thread with the add-on id, which makes attribution easier than in Chrome. - Safari: neither observer is implemented, so self-instrumentation with
performance.markis the only in-page signal. Safari also throttles background tabs harder, so a script that polls will look worse there. - All engines: injected CSS applies to the page’s own elements and can trigger a full restyle. Scope selectors as tightly as possible and prefer a class on a container over a document-wide rule.
Verification
- Add the bootstrap mark pair and load ten real sites. Anything over 16 ms deserves a Performance recording.
- Record a page load with the extension enabled and search the summary for “Forced reflow”. Any hit inside your script is the first thing to fix.
- Run the A/B script three times and compare medians. A
domInteractivedelta under 10 ms is invisible to users; over 50 ms is what the review was about. - Move the expensive pass into
requestIdleCallbackand re-run the A/B. The delta should collapse. - Check a page with a few thousand elements, not just a simple one — the quadratic patterns only show up at scale.
FAQ
Is document_idle always the right choice?
For almost everything, yes. Use document_start only when you must patch something before the page’s own scripts run, and accept that you are taking time from first paint when you do.
Does an isolated world cost extra memory?
Yes, a small fixed amount per frame — which matters on pages with many iframes. Set all_frames: false unless you genuinely need every frame.
Should I ship this telemetry to users?
Only aggregated, only with consent, and never with URLs. A hostname histogram is usually enough to find the pathological sites.
Why is the extension slower on the user’s machine than mine?
Because their machine is slower and their pages are heavier. Test with CPU throttling at 4× in the Performance panel to approximate it.
Related
- Profiling memory leaks in long-lived extension pages — the other half of a slow-feeling extension.
- Reducing service worker cold start latency — the cost on the extension’s own side.
- Best practices for content script isolation in MV3 — keeping the injected surface small.
- Performance Profiling & Optimization for MV3 Extensions — the guide this page belongs to.