Table of contents
1. What broke
The trigger was not a dramatic rewrite. It was a bundling change. Chunk naming changed, the chunk graph changed, and code that previously stayed out of the background context ended up imported by the service worker.
That imported code touched document. In a page, popup, or content script, that would be normal. In an MV3 service worker, it is not. Chrome's extension service worker docs are the right mental model here: the background context is event-driven worker code, not a DOM page. Start with Chrome's extension service worker guide if you have not internalized that boundary yet.
The failure line
const title = document.titleThe final symptom was boring and dangerous: the extension package was accepted, but the service worker did not start cleanly in production. If a user opened Chrome's extension page, they would see the worker as inactive.
2. Why build and lint missed it
A bundler can prove that JavaScript is syntactically valid. It cannot prove that every imported module is safe in every browser extension execution context. A linter might catch obvious global usage if you configure separate environments for service workers, content scripts, extension pages, and app code. Most extension projects do not go that far.
The important distinction: the broken package was build-valid but runtime-invalid. The manifest existed. The service worker file existed. The zip could be uploaded. The only thing missing was the browser doing the same work it would do after Chrome Web Store delivery.
Extra static guard: restrict DOM globals in worker files
A browser-level smoke test is still the final safety net, but linting can catch the obvious mistake earlier. If your TypeScript setup already exposes chrome through @types/chrome, keep the Chrome API available and explicitly ban DOM-only globals from service worker entrypoints with ESLint's no-restricted-globals rule or Oxlint's compatible rule.
export default [
{
files: ["src/background/**/*.{js,ts}", "src/**/*service-worker*.{js,ts}"],
rules: {
"no-restricted-globals": [
"error",
{ name: "document", message: "MV3 service workers do not have a DOM." },
{ name: "window", message: "Use self/globalThis in workers, not window." },
{ name: "localStorage", message: "Use chrome.storage in extension workers." },
],
},
},
]The rule we use now
Do not only test sources. Test the final artifact that goes to Chrome Web Store.
3. The smoke test that should exist
The smoke test is intentionally small. It does not log into the product. It does not need a sandbox account. It does not need cookies. It only answers one question: can Chromium load the final extension build and execute the Manifest V3 service worker?
Chrome's testing docs show the same browser-level idea with Puppeteer: load an unpacked extension and wait for a service_worker target. We use Playwright in our sample, but the boundary is the same. See Chrome's extension testing guide.
Minimal Playwright shape
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: [
'--disable-extensions-except=' + extensionDir,
'--load-extension=' + extensionDir,
],
})
const serviceWorker = await context.waitForEvent('serviceworker', {
predicate: (worker) => worker.url().startsWith('chrome-extension://'),
})
const response = await extensionPage.evaluate(() => {
return chrome.runtime.sendMessage({ type: 'service-worker-smoke' })
})
expect(response.ok).toBe(true)We published the full working example as Toumash/mv3-service-worker-smoke. The key part is the Playwright spec that opens the extension page and sends the worker ping.
4. Minimal GitHub Actions workflow
Keep this separate from your full E2E suite. The whole value is that it is cheap enough to run on pull requests that touch extension code, bundling, Vite config, CRXJS config, or release packaging.
name: Service Worker Smoke Test
on:
pull_request:
push:
branches:
- main
jobs:
service-worker-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: xvfb-run --auto-servernum npm run test:service-workerThe test should fail if a top-level runtime error prevents the worker from registering listeners. In our demo repo, uncommenting const title = document.title makes the build pass and the smoke test fail.
5. Release checklist
- 1.Build the same production output that goes into the release zip.
- 2.Load that output with
--load-extension, not source files. - 3.Wait for the real
chrome-extension://service worker. - 4.Ping a handler registered by your actual worker code.
- 5.Keep the smoke test independent from product APIs, sessions, and customer data.
- 6.Make it a required check for PRs changing extension build or release logic.
This will not prove your whole extension works. It will prove that the background worker is not dead on arrival. For MV3 extensions, that is worth a dedicated check.
Related articles
How to Build Chrome Extensions with React, Vite & CRXJS
The broader guide to MV3 extension architecture
CI/CD for Browser Extensions
How we hardened CRXJS with cross-platform CI
How to Get Chrome Extension Updates to Users Faster
Release pipeline lessons after Chrome Web Store approval
MV3 Service Worker Smoke Test Repo
Copy the Playwright sample into your own extension
Need help hardening your Chrome extension release pipeline?
We build and maintain production browser extensions, including CI checks for permissions, service workers, packaging, and Chrome Web Store releases.
