DEV Community

Cover image for Stream AI responses in a Chrome extension without cutoffs
ExtensionBooster
ExtensionBooster

Posted on Originally published at extenify.app

Stream AI responses in a Chrome extension without cutoffs

Originally published on Extenify.

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.

The three rules that break streaming

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:

  • 30 seconds with no events or extension API calls
  • a single event or API call running longer than 5 minutes
  • a 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.

Decide where the fetch should run

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.

The pattern: backend SSE, worker relay, content script render

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
Enter fullscreen mode Exit fullscreen mode

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.

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', ... })
});
Enter fullscreen mode Exit fullscreen mode

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.

Edge cases that become support tickets

  • User closes the panel mid-answer. Port disconnects, worker aborts its fetch, server sees the close and aborts the provider call. Test this path on purpose, since it is the one that saves you money.
  • Worker restarts anyway. Store partial output in chrome.storage.session if you need to resume.
  • Very long answers. Bound 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.
  • Provider errors. An 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.

Measure it from the server

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.

Key takeaways

  • Never call your AI API from a content script. Run the fetch in the service worker or an extension page.
  • Stream over a chrome.runtime.connect() port, not sendMessage.
  • Send headers and a first byte immediately, then heartbeats, so the worker stays alive.
  • Wire cancellation end to end: tab, worker, server, provider.
  • Render output as text and never execute it.

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. 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 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?

Top comments (0)