Keeping an MV3 Service Worker Alive Through a Long AI Stream
Arjun Mehta
Senior Browser & Extensions Analyst
Manifest V3 service workers are event-driven and disposable. Chrome terminates them after roughly 30 seconds without an event, and a fetch that is still receiving bytes does not reliably count as activity. For an AI extension streaming a 90-second answer, that termination arrives in the middle of the response: the fetch is aborted, the port closes, and the UI shows a half-finished paragraph. The fix is not a single API call. It is a design where the long-lived work happens in a context that is allowed to stay alive, with the worker acting as a router.
What Chrome counts as activity
Incoming events reset the idle timer: chrome.runtime.onMessage, chrome.tabs.onUpdated, chrome.alarms, context menu clicks, and storage change events. Extension API calls made by the worker generally do not reset the timer by themselves, and a pending fetch keeps the worker alive only while the request is active — long gaps between streamed chunks can look idle.
You can observe the real behavior from chrome://serviceworker-internals: it shows the worker's start and stop timestamps and whether it is currently running. If the worker stops while your stream is open, the response is gone. There is no reconnect.
The 30-second figure is a target, not a contract. Chrome may keep a busy worker longer and may kill an idle one sooner under memory pressure. Build for the worst case.
Put the stream in the panel, not the worker
The most reliable pattern for AI responses is to perform the streaming fetch in the side panel or popup document. Those are extension pages with a normal document lifecycle; as long as the panel is open, the fetch runs to completion. The worker handles authentication, tab queries, and short-lived orchestration, then gets out of the way.
This inverts the typical tutorial architecture, where the worker is the brain. It also removes a class of bugs: responses no longer need to be buffered in worker memory, token ordering is trivial, and cancelling a response is a local AbortController call rather than a message round-trip to a worker that may already be dead.
The trade-off is that closing the panel cancels the stream. If the product requires responses to finish while the user browses, write accumulated output to chrome.storage.session as chunks arrive and let the panel resume from the stored buffer.
When the worker must own the work, and why setInterval is a trap
Some operations cannot run in a panel: background summarization triggered by a tab event, or a batch job across saved pages. Two mechanisms keep the worker alive legitimately.
chrome.alarms is the supported timer. Creating an alarm with a one-minute period and handling it resets the idle timer on each fire. This is acceptable for scheduled polling, but using an alarm purely to defeat the idle timer is a policy smell, and reviewers have rejected extensions whose only function is to stay resident.
Message ports extend the lifetime while connected. chrome.runtime.connect from a content script or popup creates a port; the worker stays alive while the port is open and messages flow. A port alone with no traffic does not guarantee survival, so send a small heartbeat every 20 seconds while a job is in progress. Always disconnect in a finally block; orphaned ports are the usual cause of "the worker never sleeps" bug reports.
For audio capture or heavy processing that must outlive the worker, chrome.offscreen documents are the sanctioned escape hatch. An offscreen document with reason USER_MEDIA or WORKERS runs a real document context; the worker creates it, hands off the job, and can terminate without killing the work.
The widely copied workaround — calling chrome.runtime.getPlatformInfo() every 20 seconds from an injected script — keeps the worker awake and violates the spirit of the platform. It burns battery, triggers Chrome Web Store review questions about unnecessary background activity, and breaks when Chrome tightens the heuristic. It also masks the real design problem, which is that the worker was given work it should not have owned.
If a reviewer asks why your extension needs continuous background execution, "to keep our model response streaming" is not an acceptable answer. "The response streams in the visible panel" is.
A design that survives termination
Structure the extension around three roles:
- UI document (panel/popup) owns the request lifecycle, renders tokens, and persists partial output to
storage.session. - Worker owns auth, tab context extraction, and short RPC calls. Every handler is idempotent and can be re-entered after a restart.
- Offscreen document owns jobs that must continue with no visible UI.
On worker restart, the UI reconnects via chrome.runtime.connect, re-registers listeners, and asks for state. Nothing in the worker holds authoritative conversation state; Firestore or storage.local does. When a stream dies anyway — network drop, model timeout — the UI marks the message as incomplete and offers retry with the same prompt and partial context.
Test the failure path deliberately: kill the worker from chrome://serviceworker-internals mid-stream and confirm the panel recovers with a retry control instead of a spinner that never stops. Extensions that handle that case feel dependable; extensions that do not feel broken at the worst possible moment.
Instrumenting worker lifetime in the field
Local reproduction rarely matches production, where extensions run alongside dozens of others and memory pressure differs. Log worker start and stop counts per session, keyed by an anonymous install ID, and log the time between the last message and termination. Aggregate those timings before choosing a keepalive strategy; a worker that survives two minutes on a developer laptop may survive forty seconds on a low-memory Chromebook.
chrome.runtime.onSuspend fires before termination with a short window. Use it to flush buffered state to storage.session, not to perform network calls; the context is being torn down and async work will not complete. Treat it as a last-chance write, not a lifecycle hook.
The handoff that actually survives
When a streaming request must outlive a panel close, the sequence matters. Start the fetch in the panel, write chunks to storage.session on a short debounce, and record a streamId with the last received offset. If the panel reopens, it reads the buffer, requests the remaining stream from the server using the offset, and resumes. Resume-by-offset requires server support; without it, the honest UX is to restart the response and tell the user.
For an offscreen document, the worker should create it once and check hasDocument() before every creation attempt. A double-create throws and leaves the job without a consumer. Route job payloads through storage.session and message the offscreen document to pick them up, rather than passing a large object through sendMessage twice.
Measuring the cost of staying awake
Chrome's task manager reports per-extension CPU and memory. Compare an idle keepalive alarm against a connected port over a ten-minute window. The alarm wakes the worker on every fire, re-evaluating the bundle each time if Chrome discarded it; the port avoids repeated startup but keeps memory resident. On a low-end device, the port is usually cheaper; on a device with many extensions, the alarm's intermittent wake is less disruptive.
Pick the pattern that matches the product. A background summarizer that runs hourly needs an alarm and no port. An interactive assistant that streams while visible needs a panel-owned request and no keepalive at all.
Related Guides & Reviews
Chrome Side Panel API for AI Assistants: Persistent Context Without a Popup Trap
How to build a persistent AI assistant with the Chrome Side Panel API: state retention, message routing, responsive layouts, and the failure modes popup UIs create.
The 2026 AI Extension Stack: 9 Extensions That Cover 90% of Work
We tested the leading options for "best ai chrome extension stack 2026" hands-on — speed, output quality, permissions, and free-tier limits. Winner be