a translation is a port, not a message
Manifest V3 kills your service worker when it looks idle, and a long stream looks idle. The fix was to stop sending messages and start holding a connection open.
LaSu answers in up to four languages at once, and it writes the answer as the model produces it rather than waiting for the whole thing. Getting that to work inside a browser extension meant giving up on the API every extension tutorial starts with.
Three walls, in order
chrome.runtime.sendMessage is request and response. One call, one reply. There is no way to express "and here is a bit more" — which is the only thing a stream ever says. So the obvious architecture is out before you write a line of it.
The next instinct is to skip the worker and fetch from the content script. That fails for a different reason: a content script runs in the host page's origin. It is bound by whatever CORS policy the page it was injected into happens to have, and the page is some blog that has never heard of you. It cannot call the API at all.
Which leaves the service worker as the only thing that can fetch — and an MV3 service worker is terminated when it looks idle. A request that takes eleven seconds to finish streaming looks extremely idle. The browser is entitled to kill the worker mid-response, and it will.
The port is the answer to all three
Every lookup opens a named port to the worker.
- A port is a channel, not a call — the worker can push a delta whenever it has one.
- The worker fetches, so the request comes from the extension's origin and the host page's CORS policy is irrelevant.
- An open port counts as activity, so the worker stays alive for exactly as long as the request it is serving.
That third point is the one I want to underline, because it is the part that reads as a coincidence and is not. The lifetime of the connection and the lifetime of the work are the same object. There is no keepalive ping, no alarm, no timer racing the browser's idle heuristic. The worker lives while someone is listening.
It also gives you cancellation for free, in the right direction. When a card is dismissed or the reader hits retry, the port closes, and closing it aborts the fetch behind it. Without that, a dismissed card leaves a model call running to completion, billing you for tokens nobody will ever read.
// content script — one port per lookup
const port = chrome.runtime.connect({ name: "translate" });
port.onMessage.addListener(paint);
port.postMessage({ text, targets });
// dismissing the card is the whole teardown
card.onDismiss = () => port.disconnect();
// worker — the only thing on either side that fetches
chrome.runtime.onConnect.addListener((port) => {
const abort = new AbortController();
port.onDisconnect.addListener(() => abort.abort());
stream(port, abort.signal);
});One fetcher is worth more than the streaming
The reason I would keep this design even for a request that returned all at once is that it leaves exactly one thing in the extension that talks to the network.
There is one device id, because only one file generates it. There is one cache, because only one file writes it. There is one error vocabulary, so a 429 renders the same countdown whether the reader hit it from the in-page card, the popup or the options screen — and when the server changes what a 429 means, one file has to learn about it.
The version of this that used sendMessage from three surfaces had three of each of those, and they had already drifted before I noticed they were separate things at all.