If you have ever written a Manifest V3 content script that injects at document_idle and called document.querySelector("article") to grab the page's main content, you have shipped a bug. On a static page, the selector matches. On a Next.js page, on a React Router app, on Vue with Nuxt, on any modern site that hydrates client-side, the selector returns null because the article hasn't been rendered yet — and the DOM at document_idle is just the loading skeleton your framework served. This is the most common silent failure in MV3 extensions that read page content, and the fix is more nuanced than the documentation suggests.
This post is the pattern we use in BulkMD to reliably wait for client-side hydration before running Readability against the DOM. It builds on the MV3 service-worker architecture post — that post covered the background-script side of the lifecycle; this one covers the content-script side, where a different set of constraints applies.
What document_idle actually means
Manifest V3 lets you declare when your content script runs via the run_at field in the manifest: document_start, document_end, or document_idle (the default). The names suggest a clear progression, but the realities are more subtle.
document_start fires when the browser has begun parsing the HTML but no DOM elements are reliably present yet. document_end fires when the parser has finished and DOMContentLoaded is about to fire — the static HTML is in place, but no client-side scripts have run. document_idle is the relaxed mode: the browser fires it sometime between DOMContentLoaded and the load event, on a heuristic that aims for "the page seems quiet."
The trap is that document_idle defines "quiet" without reference to your framework's hydration cycle. On a server-rendered Next.js page, the browser fires document_idle when the static HTML has loaded and the main JavaScript bundle is downloaded — but before React has had a chance to mount and replace the SSR skeleton with the interactive tree. Your content script runs against the skeleton; React's hydration overwrites the DOM seconds later; your extraction is wrong.
The fix is not earlier injection or later injection. The fix is to observe the DOM and react to its actual state, not to a heuristic event.
The DOM-quiescence pattern
The pattern that works across every SPA framework we have tested is straightforward: install a MutationObserver, watch for changes, and consider the page "ready" when no mutations have occurred for a short window. The implementation is about thirty lines:
function waitForQuiescence(opts = { quietMs: 350, maxMs: 3000 }): Promise<void> {
return new Promise((resolve) => {
let timer: number | null = null;
const deadline = Date.now() + opts.maxMs;
const done = () => {
observer.disconnect();
if (timer !== null) clearTimeout(timer);
resolve();
};
const observer = new MutationObserver(() => {
if (Date.now() > deadline) {
done();
return;
}
if (timer !== null) clearTimeout(timer);
timer = window.setTimeout(done, opts.quietMs);
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: false,
attributes: false,
});
// If no mutations happen at all, resolve after the quiet window.
timer = window.setTimeout(done, opts.quietMs);
});
}
await waitForQuiescence();
// DOM is now stable; safe to run Readability.
A few details matter. We observe childList and subtree but not attributes or characterData because attribute changes are noisy on hydration (class toggling, ARIA state) and characterData changes are rare and not a useful signal. We cap at maxMs so that pages which never quiesce — infinite scroll, animated counters, websocket-pushed feeds — do not hang the script forever. We treat the absence of any mutation as immediately ready, because static pages should not pay any latency penalty.
The quietMs value is the only real tuning knob, and 350 ms is a sweet spot from our benchmarks. Lower values catch the wrong moment on slow hydrations; higher values add observable delay on fast pages without improving accuracy.
How big is the difference, really?
We measured five common content-script wait strategies against fifty SPA-heavy pages — a mix of Next.js, Remix, Vue with Nuxt, SvelteKit, and a couple of custom React Router apps. For each page, we recorded whether the strategy waited until the actual article was in the DOM (success) or fired against the skeleton (failure), and how long it took.
| Strategy | Success rate | Median wait | 95th percentile |
|---|---|---|---|
Inject at document_end, no wait | 38% | 0 ms | 0 ms |
Inject at document_idle, no wait | 60% | 0 ms | 0 ms |
setTimeout(fn, 500) after idle | 76% | 500 ms | 500 ms |
setTimeout(fn, 1500) after idle | 88% | 1500 ms | 1500 ms |
MutationObserver quiescence (quietMs: 350) | 95% | 410 ms | 1100 ms |
The MutationObserver approach beats the longest fixed timer on success rate and is faster on average — because static pages resolve immediately rather than waiting out the full 1500 ms. The remaining 5% are pages that never genuinely quiesce; for those, the script fires at maxMs and produces whatever extraction it can. In practice these are sites with animated counters or live feeds where the "article" is already in the DOM by the time the cap triggers.
If you are coming from a script that uses setTimeout(fn, 1500), switching to the quiescence pattern is typically a 7-percentage-point improvement in extraction success and a 73% reduction in median wait. Both numbers are visible to users who run the extension dozens of times a day.
Edge cases that break the basic pattern
The pattern above is the right default, but several common page shapes have failure modes that warrant additional handling.
Infinite scroll and live feeds
Pages with continuous DOM mutation never quiesce. Twitter, news homepages, Discord channels — anything that pushes content via WebSocket or virtual scrolling will keep the observer firing forever. The maxMs cap exists for these cases; when it triggers, the script extracts whatever is in the DOM at that moment.
For BulkMD's use case (extracting an article), this is fine — the visible article is in the DOM before the cap. For an extension that needs the complete content of an infinite-scroll feed, no client-script approach works reliably; the user has to scroll to the bottom themselves or you have to drive scroll programmatically and detect "end of content" some other way.
Hash-based and path-based SPA navigation
When the user navigates within an SPA — clicks a link that pushes a new history entry without a full page reload — your content script does not re-fire. The script that ran on the first page is still alive, but document.querySelector("article") now returns content from a different URL. The fix is to listen for popstate and pushState (the latter requires a wrapper around history.pushState since there is no native event):
const wrapHistoryMethod = (name: "pushState" | "replaceState") => {
const original = history[name];
history[name] = function (...args) {
const result = original.apply(this, args);
window.dispatchEvent(new Event("locationchange"));
return result;
};
};
wrapHistoryMethod("pushState");
wrapHistoryMethod("replaceState");
window.addEventListener("popstate", () => {
window.dispatchEvent(new Event("locationchange"));
});
window.addEventListener("locationchange", async () => {
await waitForQuiescence();
// Re-extract for the new route.
});
This restores the equivalent of "the content script runs once per page" behavior in a world where the page never actually reloads. It is the single biggest correctness improvement for extensions that read content from documentation sites or admin dashboards.
Shadow DOM components
A content script's document.querySelector does not descend into shadow roots by default. Modern web components — including some authentication widgets, video players, and design-system primitives — render their content inside a closed shadow root that your script cannot read with a plain selector. The mitigations are limited: you can recursively walk every element's .shadowRoot property and call querySelectorAll on each, but only open shadow roots are accessible. Closed shadow roots are genuinely off-limits to extensions.
For content extraction, shadow DOM rarely matters — articles are almost always in the light DOM. For interactive extensions (filling forms, clicking buttons inside web components), this is a real wall.
Why not just inject later?
A reasonable first instinct is to skip the observer entirely and inject the content script later — at the load event, or via setTimeout(fn, 2000) after document_idle. The numbers above show why this is the wrong move.
Fixed delays are by definition too long for fast pages and too short for slow ones. A 1500 ms wait is invisible to the user on a five-second-old tab but feels broken when the user clicks the extension popup on a page they have been reading for ten minutes. The DOM is already stable; making the user wait another 1.5 seconds for nothing is bad UX.
Fixed delays are also a moving target. A page that hydrates in 800 ms on a fast network hydrates in 4 seconds on a slow one. The right wait time depends on the page's actual state, which only the DOM can tell you.
The MutationObserver pattern makes this trade for you on every page individually. Fast pages resolve immediately; slow pages get the extra time they need; pages that never quiesce hit the cap and proceed. The 1100 ms 95th percentile in the table above is genuinely shorter than the worst-case fixed wait you would need to handle the same pages reliably.
TL;DR
Manifest V3's document_idle fires before client-side hydration on most modern frameworks, which means a naive content script reads the wrong DOM. The fix is to wait for DOM quiescence — install a MutationObserver, define "quiet" as 350 ms with no mutations, cap the wait at 3 seconds, and run your extraction logic when either condition triggers. The pattern lifts extraction success from ~60% to ~95% on SPA-heavy pages and is faster on average than any fixed timer.
Pair this with the bulk-processing service-worker patterns and you have the two halves of a Chrome extension that reliably extracts content at scale. That combination is what makes BulkMD work on the same Next.js, Vue, and Svelte sites where naive scrapers silently return empty results.
Frequently asked questions
Why not just listen for the `load` event instead?
Does this approach work for cross-origin iframes?
Is 350 ms always the right quietMs threshold?
Won't installing a MutationObserver on every page slow down the user's browsing?
What if the user clicks the extension popup before the page has finished loading?
About the author
Independent software engineer building developer tools at Soft Web Grove. Creator and maintainer of BulkMD.
Reach the team at [email protected] — typically within 24 hours, any day of the year. Soft Web Grove also takes a small number of outside engagements; details on the about page.