Manifest V3 broke a lot of patterns extension developers relied on. The persistent background page is gone, the service worker lifecycle is surprising, and the Content Security Policy is stricter than most expect. Add an LLM API call into the mix and you hit all of those at once. This is what I learned building the HireOS resume extension.
MV3 vs MV2 — what actually changed for API calls
The headline change: the persistent background page became a non-persistent service worker. In MV2 you had a long-lived background script that could hold state, keep connections open, and sit there waiting. In MV3 the worker spins up to handle an event and is terminated when idle — so anything you assumed would "just stay running" won't.
fetch() still works from the service worker, which is where your LLM call belongs. But the CSP is tighter: remotely hosted code is disallowed, and eval() / new Function() are blocked. If a dependency relies on dynamic code evaluation, it will break in an extension context — a common surprise when pulling in an SDK that wasn't written with MV3 in mind. Prefer lightweight, fetch-based calls over heavy provider SDKs.
Service worker lifecycle gotchas
The single biggest source of "why did my variable reset" bugs: the worker terminates after a short idle period (around 30 seconds of inactivity), taking all in-memory state with it. Anything you need to survive must live in chrome.storage, not in a module-level variable.
Practical rules that saved me time:
- Treat the worker as stateless. On each wake, re-read what you need from
chrome.storage. - Don't cache the API key in a global expecting it to persist — read it per request.
- Long-running LLM calls can outlive a naive event handler; keep the worker alive for the duration of an in-flight request (e.g. via a connected port) and tear it down when done.
Where the API key lives (and where it must not)
The key must never be hardcoded in source — it ships to every user and any reviewer can read your bundle. The manifest is not a safe place either.
The pattern that works: an options page where the user enters their own key, stored via chrome.storage (use local for a single device, sync if you want it to follow the user — with the understanding that synced data isn't a vault). The service worker reads the key at call time and never exposes it to content scripts or the page. A BYOK approach also sidesteps the whole problem of shipping your secret in a client-side artifact.
CSP and host permissions
To call an external LLM endpoint you must declare it. List the API origin in host_permissions in the manifest, and make sure your extension's CSP allows the connection (the connect-src directive governs where fetch is permitted to go). A call that works in unpacked local testing can still be blocked under the stricter conditions the Web Store applies — so test against the packaged build, not just load unpacked.
Messaging between content script, popup, and service worker
The three contexts can't share memory, so they talk via messages. Use chrome.runtime.sendMessage / chrome.runtime.onMessage for one-shot requests — for example, a content script asking the worker to run an inference call and return the result.
For streaming responses, one-shot messages aren't enough; open a long-lived port with chrome.runtime.connect and push tokens across it as they arrive. Keep the key inside the worker — pass the result to the content script, never the credential.
Packaging and review pitfalls
Extensions that call external APIs get extra scrutiny. The things that trigger rejection or manual review:
- Remotely hosted code — MV3's remote code policy is strict; everything executable must be in the package.
- Over-broad host permissions — request only the API origins you actually call, not
<all_urls>, or expect questions. - Unclear data handling — if you transmit user content to an LLM, your privacy disclosures need to say so plainly.
Budget for a review cycle, and make the permission list and privacy story defensible before you submit.
FAQ
Can I call OpenAI/Anthropic directly from an MV3 extension?
Yes — fetch from the service worker, with the endpoint declared in host_permissions. Avoid SDKs that rely on eval.
Where should the user's API key live?
In chrome.storage via an options page, read by the service worker at request time — never in source or the manifest.
Why does my background state keep disappearing?
The MV3 service worker is non-persistent and terminates when idle. Persist anything important to chrome.storage.