geek-five-layer-throttling-stack# Five Layers of Chrome's Tab Throttling, and a Fix for Each
Here's a bug report every scraping engineer meets eventually: *"The scraper gets 20 items from an infinite-scroll feed, then stops. Works fine when I watch the tab. Broken when it's in the background."* The usual responses — add waits, add scrolls, retry harder — fail, because the failure isn't in your code. It's in **Chrome's architecture**, which is quietly hostile to background-tab work in at least five distinct ways. [Scrapewright]( https://github.com/singhand-labs/scrapewright) contains, as far as I know, the most complete open-source taxonomy of these layers and a countermeasure for each. It's a free education in Chrome internals; let me walk it.
## Layer 1: The page checks its own visibility
**Mechanism:** JavaScript in the tab can read `document.visibilityState` and `document.hidden`. Tons of feed code gates its loading loop on "am I visible" — infinite scroll that pauses when you switch tabs. This is the *page's own* throttle, the softest layer.
**Countermeasure:** a MAIN-world injection that overrides the visibility properties to report `visible`, plus a `requestAnimationFrame` keep-alive loop. Cheap, default-on. Crucially, this fixes only the page's *belief* — it does not make Chrome do anything differently. Which brings us to…
## Layer 2: Chrome stops producing frames for background tabs
**Mechanism:** the hard one, and the root cause of most "lazy-load never fires" reports. Chrome produces compositor frames only for the **active tab of the focused window**. No frames → `IntersectionObserver` callbacks (which fire on frame-driven intersection updates) never fire → scroll-position-driven loaders never observe the sentinel entering view. You can dispatch all the events you like; the observer machinery isn't listening. This is architectural, not a timer.
**Countermeasure:** there is exactly one: make the tab actually active. Scrapewright activates the scrape tab for the duration of an operation ("sticky" activation — it stays active across back-to-back operations instead of activate/restore churn, and restores the user's last-clicked tab when the scrape tab closes). The engineering here is the choreography — suppressing the extension's own activation events so it can tell its own tab switches from the user's, surviving MV3 service-worker suspension via session storage, handling window focus — all in `lib/tab-activation.js`. It's some of the most careful code in the repo.
## Layer 3: Chrome throttles background timers/rendering policy
**Mechanism:** even with frames, background tabs get throttled timers and, on some platforms, occlusion-based de-prioritization (`CalculateNativeWinOcclusion` on Windows, renderer backgrounding generally).
**Countermeasure:** launch flags. The repo ships a CLI (`scrapewright throttle on`) that rewrites your Chrome launcher (per-OS: `.desktop` file, wrapper app, shortcut) to add `--disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion`. Necessary-but-not-sufficient: flags alone don't fix layer 2, which is why people who "already tried the flags" still had broken scrapers.
## Layer 4: The page filters untrusted input
**Mechanism:** with the tab active and frames flowing, you scroll — and nothing loads. Because the page's loader checks `event.isTrusted` on scroll events, and programmatic `scrollBy` is spec-level untrusted forever.
**Countermeasure:** the surgical one — transiently attach CDP via `chrome.debugger` and issue `Input.dispatchMouseEvent` wheel events, which enter through Chrome's real input pipeline and arrive with `isTrusted: true`. Only `Input.*` commands (minimal detection surface), capped attempts, deployed only when programmatic scroll stalls. (This deserves its own article — and the repo's whitepaper gives it one.)
## Layer 5: When ops interleave
**Mechanism:** subtler — hover-card enrichment, detail-page drill-down (`$openTab` sub-tabs), dismiss-after-hover: each of these performs CDP input or waits for rendering, and each one independently needs an *active* tab. Fix activation for one op and the next op still runs into layer 2 again; JS-heavy detail pages mount an app shell in a background tab and then never render the content — every extraction on them returns deterministically empty.
**Countermeasure:** activation as *infrastructure*, not per-op. Every path that touches frames or input — scroll, hover, hover-dismiss, sub-tab loading — routes through the same activation request. The lesson the repo's history makes explicit: same mechanism → same infrastructure, everywhere, or you re-learn it per feature.
## Why this taxonomy matters
The five layers are *independent mechanisms* that all manifest as the same symptom — "background scraping under-retrieves." Fixing one reveals the next; the failure chain is why naive fixes stall out. If you want the short version burned in:
| Layer | What's actually throttled | Fix |
|---|---|---|
| 1 | Page JS's visibility check | Override `visibilityState` (MAIN world) |
| 2 | Compositor frame production | Real tab activation (sticky) |
| 3 | Background timers/occlusion | Chrome launch flags |
| 4 | `isTrusted` filtering | CDP `Input.dispatchMouseEvent` wheel |
| 5 | Per-op re-throttling | Activation as shared infra for every op |
Even if you never scrape anything, this stack is one of the best practical documents on how Chrome actually schedules work — the whitepaper (§9) explains each layer with the failure story that uncovered it.
**Repo:** [github.com/singhand-labs/scrapewright]( https://github.com/singhand-labs/scrapewright) — GPLv3. Start in `lib/visibility-keepalive.js`, `lib/tab-activation.js`, and `lib/scroll-ops.js`, then read whitepaper §9 with the code open.aaa69532