Connecting a Chrome Extension to a Node.js API
TIL that a fetch call from a content script that worked fine in the console failed silently from the actual extension, because content scripts run in an isolated world with their own CORS and CSP rules.
The Problem #
A fetch call to the delivery API worked when I pasted it directly into DevTools' console on the POS page, and failed silently — no response, no obvious error in my own code's try/catch — when the identical call ran from the content script itself.
Context #
Content scripts run in an "isolated world": same DOM access as the page, but a separate JavaScript execution context with its own extension-specific security rules, which don't behave identically to a script the page itself would run.
What I Tried #
Added more logging around the fetch call, assuming I was swallowing an error somewhere.
What Went Wrong #
There was no error to catch — the request was being blocked before it ever resolved or rejected in a way my try/catch could see, because the extension's manifest hadn't declared the API's origin as a permitted host, so Chrome blocked the cross-origin request at a level below my own code.
The Solution #
Declared the API's origin explicitly in the extension's host_permissions, and moved the actual network call into the background service worker (which has broader network permissions than a content script) rather than firing it directly from the content script.
// manifest.json
{
"host_permissions": ["https://api.delivery-app.com/*"],
"permissions": ["storage"]
}// content script asks the background worker to make the request
chrome.runtime.sendMessage({ type: "SYNC_ORDER", order }, (response) => { ... });Why It Works #
Manifest V3 restricts what a content script can do to the page it's injected into and requires the extension to explicitly declare which external origins it's allowed to talk to. Routing the network call through the background worker — which is the extension's own privileged context, not the isolated-but-still-page-adjacent content script — is both more permission-appropriate and keeps network logic out of a script that's re-injected on every page load. Debugging "it works in DevTools but not in my code" inside an extension needs a different mental model than debugging a normal web page — content scripts, background workers, and the page itself are three separate execution contexts with different rules, not one script running in three places.
Lessons Learned #
A silent failure with no thrown error is often a permissions or context boundary issue in extension code, not a logic bug — worth checking host_permissions and execution context before assuming the fetch call itself is wrong.
What I Would Do Differently #
I'd set up the background-worker message-passing pattern for all network calls from the start, instead of discovering content scripts aren't the right place for them after an afternoon of debugging a fetch that silently went nowhere.
Related Concepts #
Content script isolated worlds, host_permissions, chrome.runtime.sendMessage for cross-context communication.
Related content
Serving Premium Downloads Without Exposing the File's Real Storage URL
TIL how to replace a guessable direct-download link with a permission-checked, time-limited presigned URL, so the object storage path a purchased asset actually lives at is never sent to the client.
Live Poll Results Without WebSockets: Server-Sent Events in a Node.js API
TIL that once vote writes were race-free, the results screen still felt dead until a manual refresh — switching from client polling to Server-Sent Events made results update instantly without the bidirectional complexity WebSockets would have added for a channel that only ever sends in one direction.
Designing a Complete Delivery-Management System as a Solo Developer
TIL that a system spanning a browser extension, a React Native app, an API, and a database only stayed maintainable solo because I treated the API as the one real product and everything else as a thin client of it.