Chrome ExtensionsManifest V3PlaywrightCI/CD

Chrome Web Store accepted our build. The MV3 service worker was dead.

Published · 8 min read · By the Optymized team

A bundling change moved code into our Manifest V3 background service worker. That code touched document. The production build passed. The Chrome Web Store package was valid. But in chrome://extensions, the service worker was inactive.

This is the small CI check we added after that failure: build the exact extension artifact, load it as an unpacked extension in Chromium, wait for the MV3 service worker, and ping the real worker.

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.title

The 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-worker

The 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

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.

Who's behind this

Tomasz Dłuski

Tomasz Dłuski

Founder & CEO

Senior Software Engineer with 10+ years of experience. Previously part of a company that scaled from 5 to 50+ engineers. Now building Optymized — a company that combines enterprise project delivery experience with own SaaS products. Maintainer of CRXJS (3.9k GitHub stars), one of the most popular tools for building browser extensions.

Let's discuss your project

Whether you need a custom browser extension, a dedicated dev team, or technical consulting — let's find the best approach together.

or send us a message