Content Script run_at Timing Explained
What document_start, document_end and document_idle actually mean in MV3 — what exists at each point, which one to choose, and how to wait for the DOM you need.
Table of Contents
run_at has three values and most extensions pick one by superstition. The consequences are concrete: at document_start your script beats the page’s own code but document.body does not exist; at document_idle the DOM is complete but the page has already done whatever you wanted to intercept. Getting this right is usually the difference between a feature that flickers and one that does not. This guide is part of content scripts and DOM injection.
What exists at each point
document_start— afterdocument.documentElementexists and before any page script has run.document.headmay be partially populated;document.bodyisnull. Nothing the page defines is available yet.document_end— immediately after the DOM is complete, equivalent toDOMContentLoaded. Images and stylesheets may still be loading. The page’s parser-inserted scripts have run.document_idle— the browser picks a moment betweenDOMContentLoadedandload, based on how busy the page is. The default, and the least predictable.
Step-by-step: choosing and coping
1. Use document_start only for interception
The one thing document_start can do that nothing else can is get in front of the page.
1// content/early.js at document_start, in the MAIN world
2const originalFetch = window.fetch;
3window.fetch = async function (...args) {
4 const res = await originalFetch.apply(this, args);
5 window.dispatchEvent(new CustomEvent("ext:fetch", { detail: { url: String(args[0]) } }));
6 return res;
7};
Execution context: the page’s main world, at document_start. In the isolated world this patch would be invisible to the page — the two worlds share a DOM, not a global object. Patching must call through and return the original result, or the page breaks.
2. Do not touch document.body at document_start
1// Wrong at document_start — body is null.
2document.body.appendChild(panel);
3
4// Right — mount on documentElement, which always exists.
5document.documentElement.appendChild(panel);
Execution context: the content script at document_start. documentElement is guaranteed by the time a document_start script runs, which is what makes it the correct mount point for injected UI regardless of timing.
3. Insert CSS early to avoid a flash
If the extension hides or restyles page elements, doing it at document_idle means the user sees the unstyled version first. A stylesheet inserted at document_start applies as the elements are parsed.
1{
2 "content_scripts": [{
3 "matches": ["https://example.com/*"],
4 "css": ["content/hide.css"],
5 "run_at": "document_start"
6 }]
7}
Execution context: parsed at install; the CSS is applied by the browser before the matching elements are painted. Note that css at document_start is safe even though js at document_start is constrained — stylesheets do not need the DOM to exist.
4. Wait for what you need rather than guessing
document_idle is not a guarantee that the element you want exists, because the page may create it from script. Waiting explicitly is more reliable than picking a later run_at.
1function ready(fn) {
2 if (document.readyState === "loading") {
3 document.addEventListener("DOMContentLoaded", fn, { once: true });
4 } else {
5 fn();
6 }
7}
Execution context: the content script. This handles the case where a document_start script needs the DOM without changing run_at — which matters because run_at is per-script, and you often want one script that does both early interception and late decoration.
5. Understand document_idle’s variability
The browser may run an idle script immediately after DOMContentLoaded on a light page and delay it until close to load on a heavy one. A feature that works on your test page and flickers on a news site is usually this.
1performance.mark("ext:start");
2ready(() => {
3 performance.mark("ext:dom");
4 performance.measure("ext:startup", "ext:start", "ext:dom");
5 console.debug(performance.getEntriesByName("ext:startup")[0].duration);
6});
Execution context: the content script. The measurement is worth taking on real sites rather than a fixture — the spread between a documentation page and a news homepage is routinely an order of magnitude, and it is the spread that causes the bug reports.
The cost of running early
A document_start script sits directly on the critical path: the parser is blocked while it executes, on every matching navigation, before anything is painted. A script that takes 30 ms to parse and run adds 30 ms to first paint on every page it matches.
That makes the size discipline stricter than for later injection points. An early script should do one thing, do it in a few kilobytes, and hand off:
1// content/early.js — small, synchronous, no imports
2if (location.pathname.startsWith("/app")) {
3 document.documentElement.dataset.extReady = "1";
4 import(chrome.runtime.getURL("content/late.js")); // fire and forget
5}
Execution context: the content script at document_start. The dynamic import resolves asynchronously and does not block the parser; the early script’s own work is a string comparison and a dataset write. The imported file must be in web_accessible_resources.
The second cost is breadth. A document_start script matching *://*/* runs before first paint on every page in the browser. Even at a millisecond each, that is a tax the user pays continuously — which is the measurement discussed in measuring content script impact on page load.
Two scripts, two timings, one feature
Because run_at is set per content-script entry rather than per extension, the cleanest answer to “I need to be early and late” is two entries sharing one feature. The early one does the work that must beat the page; the late one does everything that needs a DOM.
1{
2 "content_scripts": [
3 {
4 "matches": ["https://app.example.com/*"],
5 "css": ["content/prehide.css"],
6 "js": ["content/early.js"],
7 "run_at": "document_start"
8 },
9 {
10 "matches": ["https://app.example.com/*"],
11 "js": ["content/main.js"],
12 "run_at": "document_idle"
13 }
14 ]
15}
Execution context: parsed at install. Both entries inject into the same isolated world for a given document, so content/early.js can leave state on window that content/main.js reads — a private channel the page cannot see.
1// content/early.js
2window.__extEarly = { startedAt: performance.now(), interceptedUrls: [] };
3
4// content/main.js
5const early = window.__extEarly;
6console.debug("[ext] early ran", performance.now() - early.startedAt, "ms ago");
Execution context: both run in the content script’s isolated world for that document. This is the supported way to hand data forward in time, and it is considerably simpler than storing it and reading it back.
The pattern also solves the flash problem cleanly. prehide.css at document_start sets visibility: hidden on the elements you are about to restyle; main.js removes the class once it has done its work. The user never sees the intermediate state, and the page is never left hidden if your script fails — because the rule is scoped to a class only your early script adds.
1/* content/prehide.css */
2html.ext-pending .ext-target { visibility: hidden; }
Execution context: applied by the browser at parse time. The class is added by early.js and removed by main.js; a crash in between leaves the class on, which is why a timeout that removes it unconditionally after a couple of seconds is worth adding.
Cross-browser variation
- Chrome / Edge: the three values behave as described.
document_idleis scheduled by the browser’s own heuristics and can differ between runs on the same page. - Firefox: same three values with the same semantics. Firefox tends to run
document_idlescripts closer toDOMContentLoadedthan Chrome does, which can mask a timing assumption that fails on Chrome. - Safari: supports all three;
document_starttiming is slightly later relative to the parser, so a main-world patch intended to beat the page’s first script is less dependable. Verify interception on Safari explicitly rather than assuming. - All three:
run_atapplies per content script entry, not per file. Two entries with different timings can share a file if you need one script at each point.
Verification
- Confirm what exists when your script runs:
1console.debug("[ext] run_at check", {
2 readyState: document.readyState,
3 hasBody: !!document.body,
4 scripts: document.scripts.length,
5});
6// document_start → { readyState: "loading", hasBody: false, scripts: 0 }
Execution context: the content script. Run it on a heavy page as well as a light one — document_idle in particular reports quite different numbers on each.
- For an interception patch, confirm it is in place before the page uses the API by logging from both the patch and the page’s first call.
- Compare first-paint timings in DevTools Performance with the extension enabled and disabled on the same page.
FAQ
Which should I use by default?
document_idle for anything that decorates the DOM, document_start for CSS that prevents a flash and for main-world interception. document_end is rarely the best answer for either.
Can a document_start script use chrome.storage?
Yes — chrome.storage is available in the isolated world from the first line. It is asynchronous, so a value read there arrives after the parser has moved on, which is fine for decoration and useless for interception.
Does run_at affect executeScript?
executeScript accepts injectImmediately, which is closer to document_start semantics, but it still cannot run before the worker has woken and the call has been made. For genuinely early code, use a registration or the manifest.
Related
- Declarative vs programmatic content script registration — which mechanism can reach each timing.
- Injecting UI with shadow DOM without breaking the page — mounting safely before body exists.
- Best practices for content script isolation in MV3 — the world your early script runs in.
- Content scripts and DOM injection — the parent guide.