# Stream AI responses in a Chrome extension without cutoffs

> Source: <https://dev.to/extensionbooster/stream-ai-responses-in-a-chrome-extension-without-cutoffs-1gg9>
> Published: 2026-11-11 09:16:00+00:00

*Originally published on [Extenify](https://extenify.app/blog/stream-ai-responses-chrome-extension?ref=devto).*

If you stream AI responses in a Chrome extension the way you would on a normal web page, it usually works on your machine and then fails for real users with the classic bug report: "the answer stops in the middle." The cause is rarely your model or your prompt. It is three Manifest V3 platform rules, and once you design around them the fix is small.

**1. Content scripts borrow the page's origin.** Chrome's docs say content scripts make requests on behalf of the site they are injected into, and cross-origin requests stay cross-origin even if your extension has host permissions. Your API is cross-origin for every site a user visits, so the content script is the wrong place to call it.

**2. `sendMessage` carries exactly one reply.** That is why you see "The message port closed before a response was received". A token stream needs a channel that stays open.

**3. The service worker can be stopped mid-answer.** Chrome's lifecycle page lists three triggers:

`fetch()` response that takes more than 30 seconds to arrive
From Chrome 114, *sending messages* on a long-lived port keeps the worker alive. Just opening the port no longer resets the timers, so messages have to keep flowing.

`EventSource` is not a shortcut either: it only does GET and cannot set an auth header, so you cannot send a prompt with it. Use `fetch()` with POST and read the body stream.

Before writing any relay code, check where your UI lives:

| UI location | Run the fetch in | Why | 
|---|---|---|
| Side panel or options page | That page | Extension pages are not bound by the worker's idle rules | 
| Popup | Popup or service worker | The popup closes when it loses focus, killing the stream | 
| Inline UI from a content script | Service worker, relayed over a port | The case that needs the full pattern | 

If your feature lives only in a side panel, a plain `fetch()` loop is enough. Most AI extensions also want an inline UI next to a text field or selection, and that is where the port relay comes in.

**Backend.** Keep the API key on your server, never in the package, where anyone can unzip it. The server should flush headers and a first byte immediately (so the extension's `fetch()` resolves well inside 30 seconds), send a heartbeat event about every 15 seconds while the model is thinking, and abort the upstream provider call when the client disconnects. If nginx sits in front, send `X-Accel-Buffering: no`, or it will buffer the stream into one late block. Normalize Anthropic's `content_block_delta` and OpenAI's `response.output_text.delta` events into one tiny shape so the extension never cares which provider answered.

```
res.writeHead(200, {
  'content-type': 'text/event-stream',
  'cache-control': 'no-cache',
  'x-accel-buffering': 'no',
});
res.write(': open\n\n'); // first byte right away
```

**Service worker.** Listen with `chrome.runtime.onConnect`, run the `fetch()`, and post each chunk back on the port. Forward the server's heartbeat as a message too. Reasoning models can stay silent for a long time before the first token, and a silent port gives Chrome no reason to keep the worker running. Tie an `AbortController` to `port.onDisconnect` so closing the tab cancels the request.

``` js
chrome.runtime.onConnect.addListener((port) => {
  const ctrl = new AbortController();
  port.onDisconnect.addListener(() => ctrl.abort());
  // fetch(API_URL, { signal: ctrl.signal, ... })
  // for each SSE event: port.postMessage({ type: 'delta' | 'ping' | 'done', ... })
});
```

Remember to list your API host in `host_permissions`.

**Content script.** Open the port with `chrome.runtime.connect()`, send the prompt, and append text as it arrives. If `onDisconnect` fires before a `done` message, the answer was interrupted: keep the partial text and show a retry button.

Two rendering rules matter here. Model output is untrusted, because a model can echo markup that was planted on the page, so append with `textContent` or run Markdown through a sanitizer. And never execute it: Manifest V3 bans remotely hosted code, and evaluated server output is exactly that.

`chrome.storage.session` if you need to resume.`max_tokens` and test your longest realistic answer on the oldest Chrome version you support, because how the 5-minute limit applies to a long stream is not spelled out.`overloaded_error` mid-stream or a retired model ID should show a clear message, not a frozen cursor. Keep model IDs in server config so a model shutdown does not need a store release.
Streaming changes how fast an answer feels, not what it costs. Your server already sees every request, so log time to first token, completion rate (reached done vs. disconnected vs. errored), errors by type, and tokens per request. No extra tracking in the extension is needed.

`chrome.runtime.connect()` port, not `sendMessage`.
The original post has the complete SSE parser, the dependency-free Node.js server and the full relay code in its [step-by-step guide to streaming AI responses in a Manifest V3 Chrome extension](https://extenify.app/blog/stream-ai-responses-chrome-extension?ref=devto). Once it ships, a stalling AI feature tends to show up in store reviews within days, which is what [Extenify's daily Chrome, Edge and Firefox listing tracking](https://extenify.app/?ref=devto) is built to catch.

Have you hit the "answer stops halfway" bug in your own extension, and was it the service worker, a proxy buffering the stream, or something else?
