Stream AI responses in a Chrome extension without cutoffs A developer published a guide showing how to stream AI responses inside a Chrome extension under Manifest V3, tracing the common "answer stops in the middle" bug to three platform rules: content scripts inherit the page's origin, sendMessage carries only a single reply, and the service worker can be terminated mid-answer. The fix routes the fetch through the service worker over a long-lived port, keeps the worker alive with heartbeat messages roughly every 15 seconds, ties an AbortController to port.onDisconnect, and has the backend flush a first byte immediately with X-Accel-Buffering disabled to avoid nginx buffering. 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?