Cross-Origin Resource Sharing (CORS) blocks a content script from posting scraped data straight to your own API. The fix is to move the network request out of the content script and into the extension's background service worker, then list your target domains under host_permissions in manifest.json. A Manifest V3 background worker running with declared host permissions is not bound by the page's CORS policy, so the request goes through without any server-side CORS headers on either end. This guide walks through the full setup: the permission model that makes it work, a local deduplication layer so you don't flood your API with repeat submissions, and a background sync pipeline that pushes clean data to your server.
Tested with [Chrome version] on Manifest V3, using chrome.storage.local for the deduplication layer and a background service worker for all outbound API calls. Last updated [date].
I ran into this while building a scraper for a client project that needed to pull listing data from a public site and sync it into our own backend. The obvious approach, a fetch() call from the content script, hit CORS immediately since the target site sends no Access-Control-Allow-Origin header. Switching the request to the background worker solved it in about ten minutes once I understood why it worked.
The Architecture Overview
mermaid
graph TD
A[Target Web Page] -->|Injects| B(Content Script)
B -->|Scrapes DOM| C{Page Type?}
C -->|Detail page| D[Detailed Listing + Contact info]
C -->|List/search page| E[Profile summary header]
D -->|chrome.runtime.sendMessage| F(Background Service Worker)
E -->|chrome.runtime.sendMessage| F
F -->|Local Merge & Deduplicate| G[(Storage: chrome.storage.local)]
G -->|Evaluate Sync| H{Needs Syncing?}
H -->|Yes: Listings| I[POST /api/listings/import]
H -->|Yes: Profiles| J[POST /api/profiles/import]
B -->|Asks for state| F
F -->|Return visited/captured statuses| B
B -->|Injects badges| A
Two page types get scraped: a detail page with full specifications and contact information, and a list or search page that only exposes a summary. The content script reads the DOM on each, hands the payload to the background worker, and the worker owns everything from that point: merging, deduplication, and the actual network call.
1. Bypassing CORS via the Background Service Worker
In Manifest V3, content scripts run inside the page's origin and inherit its CORS restrictions. Background service workers run in an isolated extension context. When you list your target hosts under host_permissions, Chrome grants the service worker direct fetch() access to those domains, without a CORS preflight and without needing any cooperation from the target server.
Configuring manifest.json
json
{"manifest_version":3,"name":"Listing Data Extractor","permissions":["storage"],"host_permissions":["*://*.target-site.example/*","https://your-api.example/*"],"background":{"service_worker":"src/background.js","type":"module"}}
The host_permissions entries cover both the site being scraped and the API receiving the data. Chrome checks these at install time and again at every update, so keep the list as narrow as the task allows. Broader wildcard patterns draw more scrutiny during Chrome Web Store review and offer no benefit once the two real domains are known.
2. Scraping and Local Deduplication
Scraping happens on two page types:
Detail page: full specifications, image galleries, and contact information for a single listing.
List/search page: a summary card for a profile or seller, appearing across many listings at once.
Resubmitting the same profile or listing on every page reload wastes API calls and clutters your backend with duplicate writes. chrome.storage.local gives you a place to track what's already been sent, so the worker can skip anything unchanged.
Deduplication Logic (storage.js)
A profile syncs only when it is fully populated and either new or changed since the last sync:
The isFullyPopulated check exists because list pages often expose a partial profile. Syncing a half-complete record just means syncing it again later once the detail page fills in the gaps, so the check holds off until there's something worth writing. The infoChanged comparison catches edits on the source site between visits, which matters if you're relying on this data staying current.
3. Background Syncing Orchestration
The background script receives messages from the content scripts, writes them to local storage, and triggers the sync calls:
The return true on the listener keeps the message channel open while handleMessage runs asynchronously, since sendResponse would otherwise fire before the API calls finish. Keeping the listing sync and the profile sync as two separate calls, rather than bundling them into one payload, means a failed profile sync doesn't block the listing from saving. The listing is usually the higher-priority write, so it goes first and succeeds independently.
4. Real-Time DOM Badges
To show the user what's already been captured, the content script asks the background worker for status on the listings visible in the current view, then injects a badge on each card.
✓ Reviewed (Green): Detail page visited, fully scraped, and synced.
✓ In database (list only) (Blue): Captured from a list sweep, detail page not yet visited.
This function runs once per page load on any list view, batching all visible IDs into a single message rather than querying storage per card. The badge color does the explaining on its own, so a user scanning a results page can tell at a glance which listings still need a visit without opening each one.
FAQ
Does this approach work in Manifest V2?
Yes, the same principle applies. Manifest V2 background pages have the same CORS exemption when the domain is listed under permissions rather than host_permissions. The API and lifecycle differ, but the core mechanism, background context bypassing content-script CORS restrictions, is the same in both manifest versions.
Will broad host_permissions get flagged during Chrome Web Store review?
Wildcard host permissions typically draw closer review and may require a written justification during submission. Scoping the list to the specific domains you actually call, rather than a broad wildcard, reduces friction and is worth doing regardless of review outcomes.
What happens if the target site changes its DOM structure?
The scraper breaks silently unless you add validation. A useful pattern is to check for the presence of expected fields before treating a scrape as complete, and to log a warning when a previously reliable selector returns nothing, so you catch the change quickly instead of silently syncing empty records.
Does this bypass CORS on sites behind Cloudflare or similar bot protection?
CORS and bot protection are separate layers. This technique solves the CORS restriction on cross-origin requests. It does nothing about rate limiting, fingerprinting, or challenge pages, which need to be handled separately if the target site has them.
Could the content script make the API call directly instead of relaying through the background worker?
No. The content script still runs in the page's origin and is still subject to that origin's CORS policy no matter what permissions the extension declares. The relay through the background worker is what makes the bypass work, not the permissions alone.
Summary & Key Takeaways
Background delegation bypasses CORS. Manifest V3 background workers running with host_permissions are not bound by the page's CORS policy. Route API traffic through them instead of the content script.
Local storage keeps sync traffic lean.chrome.storage.local lets you track what's already synced and skip redundant writes.
DOM badges close the feedback loop. Reflecting sync status back onto the page gives the user a clear picture of what's done without leaving the site.
Let me know in the comments if you have questions, and subscribe for more practical development guides.