•
6 min read
five things chrome does to a background tab, and what each one breaks
autopilot chrome cdp debugging

The rule for Autopilot is that an agent must never bring a tab or window forward. It is a design constraint rather than a setting: nothing in the code activates a tab or focuses a window. That single rule runs into five Chrome behaviors that none of the documentation mentions, each of which silently breaks the obvious implementation. Here they are, with what each one broke and what fixed it.

a pending timer does not keep a service worker alive

A Manifest V3 extension’s background code runs in a service worker that Chrome suspends whenever it is idle. Awaiting a setTimeout counts as idle. A 100 ms hover delay before a click measured about five seconds in practice, because the worker was suspended mid-wait and resumed on the next event, and every click paid it.

The fix moves timed waits onto the page’s clock. The extension runs Runtime.evaluate with a promise-wrapped setTimeout inside the tab, and the worker awaits a pending extension API callback. That does hold it open. With no attached tab, the wait falls back to the worker clock and accepts the cost.

The general form: a service worker stays alive while it has an outstanding extension API call, and for nothing else. Anything you want to survive an idle window has to be shaped as one.

input dispatch blocks for five seconds on a throttled renderer

Input.dispatchMouseEvent answers only once the renderer has processed the event. A throttled renderer, which a background tab’s renderer is, holds that answer for a fixed five seconds. The event was delivered. The acknowledgement was not.

The fix dispatches with no command timeout and races the acknowledgement against a 400 ms sleep. If the acknowledgement does not arrive in time, the call moves on, records the tab as throttled, and the next action corrects the throttling before dispatching. Blocking on the acknowledgement was the natural implementation and it made every click on a hidden tab cost five seconds.

a hidden tab does not answer, until you tell it that it is visible

Chrome throttles a hidden tab in three ways at once: it holds input acknowledgements, stops requestAnimationFrame, and reports document.visibilityState as hidden. Pages that pause on hidden, which is most of them with any video or polling, stop.

Two CDP commands on attach undo all three: Emulation.setFocusEmulationEnabled and Page.setWebLifecycleState with state active. Measured on Chrome 152: the tab answers a mouse event in 1 ms, animation frames run, timers stay accurate, and the page reports itself visible and focused, with the window minimized. This is the mechanism that makes background mode possible at all, and it is two calls.

a hidden tab’s screenshot never returns

A surface capture of a hidden tab, the ordinary Page.captureScreenshot, takes seconds or never returns, because there is no surface: the compositor has nothing to composite for a tab nobody is looking at. On some builds the extension debugger API refuses renderer captures outright with “Only screenshots from surface are allowed”.

The capture path has three tiers. It first probes Page.captureScreenshot with fromSurface: false, which asks the renderer for a fresh frame and carries clip and scale natively. If that is refused, it falls back to a screencast, which has its own trap: a sleeping tab emits no frame at all, and the first frame a screencast opens with is the surface as it was before the redraw the request forced. So the fallback wakes the tab with force, waits two animation frames, and opens screencasts until two consecutive frames carry the same image. A window that produces no frame is raised as a timeout so the read retry policy handles it rather than the capture code guessing.

Measured: 58 ms when I first measured it and 107 ms on a later check, against 3 to 4 seconds for a surface capture of the same tab.

Cost is a separate problem from latency. Claude’s vision pipeline downscales anything wider than 1568 pixels and bills roughly one token per 28 by 28 pixel block. Capping the long edge alone leaves a tall viewport under 1568 wide and still costing about 2,800 tokens. The planner bounds area, at a token budget times the pixels per token squared, so captures land at roughly 1,600 tokens whatever the window shape. A separate byte budget steps JPEG quality down before it reduces dimensions. Measured: 486 tokens at 0.85 scale on a news homepage against 672 unscaled. The byte target was missed, and I wrote that down rather than rounding it up: JPEG against PNG at the same dimensions is 2.2x, short of the “several times” the plan hoped for.

chrome serves stale code from a compiled module cache

Chrome keeps a compiled module graph for extension service workers and reuses it across browser restarts. A cold start can run stale code while serving the edited file over the extension URL, so the source you read and the code that runs disagree, with no error. On Chrome 152 the directory is Default/Extension Scripts in the profile. The launcher script deletes it on every start.

chrome.runtime.reload() does not clear it, and on Chrome for Testing 152 it does something worse: it disables an unpacked extension outright. The profile records a disable reason, the worker and offscreen targets are gone, creating a tab does not wake it, and only a browser restart restores the bridge. Three runs, deterministic. So the normal developer reload path does not exist for this project, and every change under the extension directory bumps the manifest version so that the doctor printing the running version proves a reload took.

what is still not fixed

Capture latency drifts upward on a browser that has been open for days, and the cause is not established. The reload behavior above is worked around rather than fixed. Window occlusion, where a renderer behind another window drops input outright, is removed from the test launcher with a flag and left alone in the user’s real browser, where the wake commands cover it.

None of the five is in Chrome’s extension documentation. All five were found by driving a real browser, in the background, and noticing that a number was an order of magnitude past what it should be.