Fixing an MSW Service Worker 404 in Vite

[MSW] Failed to register a Service Worker in the console, mockServiceWorker.js returning 404 in the network panel, and no request intercepted. This page walks the four causes in the order they actually occur and gives one command that tells them apart.

Context: three separate things must line up

Registering a Service Worker looks like one step and is three, and a failure in any of them produces the same console message.

The file must exist, generated by npx msw init. It is a real script MSW ships, copied into your project rather than served from node_modules.

The URL must resolve to that file. Vite serves the public directory at the base path, so a worker in the wrong directory, or an application with a non-root base, produces a 404 — or worse, an HTML fallback with status 200, which fails registration with a MIME type error rather than a 404 and sends everyone hunting in the wrong place.

The scope must cover the requests you want intercepted. A worker registered at /app/mockServiceWorker.js controls /app/** and nothing above it, so a fetch to /api/orders sails past.

Four places this breaks, and what each looks like A chain from the generated worker file, through the served URL, the registration call and the worker scope, to a controlled page. Each link is annotated with the symptom of its failure: a missing file gives a 404, a wrong URL gives an HTML fallback and a MIME type error, a stale generated worker gives a version warning, and a too-narrow scope gives successful registration with nothing intercepted. public/ mockServiceWorker.js served at {base}mockServiceWorker.js worker.start() registers it scope covers the fetch URL intercepted missing → 404 run npx msw init wrong dir or base → HTML fallback "unsupported MIME type text/html" stale worker → version warning re-run init after every upgrade narrow scope → intercepts nothing the most confusing of the four All four produce a broken mock; only the first produces the 404 people search for.

Solution

1. One command that identifies the cause

curl -s -o /dev/null -D - http://localhost:5173/mockServiceWorker.js | head -3

Read the first two lines:

  • 404 — the file is not being served. Go to step 2.
  • 200 with content-type: text/html — Vite’s SPA fallback answered. The URL is wrong, not the file. Go to step 3.
  • 200 with content-type: text/javascript — the file is fine; the problem is scope or staleness. Go to step 4.

This distinction is worth the ten seconds because the second case is the one that wastes an afternoon: the status is 200, so it looks fine, and the actual browser error mentions MIME types rather than anything about a missing file.

2. Generate the worker into the right directory

# Writes public/mockServiceWorker.js and records the path in package.json
npx msw init public/ --save

--save adds an msw.workerDirectory entry to package.json, which lets MSW warn when the worker is stale after an upgrade. Without it, an upgraded MSW and an old worker script coexist silently.

Commit the generated file. It is a build input, not an artefact — a fresh clone with no worker produces the same 404 for the next person.

# Do NOT ignore this — it must be committed
# public/mockServiceWorker.js

3. Account for a non-root base path

If vite.config.ts sets a base, the worker URL changes with it and the default registration path breaks:

// vite.config.ts
export default defineConfig({
  base: '/app/',
  publicDir: 'public',      // the default; shown for clarity
});
// src/mocks/start.ts
import { worker } from './browser';

export async function startMocks(): Promise<void> {
  await worker.start({
    onUnhandledRequest: 'error',
    serviceWorker: {
      // BASE_URL already includes the trailing slash Vite normalises it to.
      url: `${import.meta.env.BASE_URL}mockServiceWorker.js`,
      options: { scope: import.meta.env.BASE_URL },
    },
  });
}

Deriving both the URL and the scope from import.meta.env.BASE_URL means changing the base in one place keeps working. Hard-coding /mockServiceWorker.js is what breaks the moment the app is deployed under a path.

4. Widen the scope, or move the worker up

A Service Worker can only control URLs at or below its own path. A worker at /app/mockServiceWorker.js cannot intercept /api/orders, and the registration succeeds regardless — so the symptom is “everything looks fine and nothing is mocked”.

Two fixes, and the first is almost always right:

Use relative API paths. If the application fetches /app/api/orders rather than /api/orders, the scope covers it and nothing else needs changing. Combined with the network layer abstraction pattern this is a one-line configuration change.

Serve the worker from the root and set a broader scope, which requires the Service-Worker-Allowed header from the server — available in a custom dev-server middleware but not from the static public directory alone.

5. Await start before rendering

Registration is asynchronous. A component that fetches on mount can easily beat it:

// src/main.tsx
import { createRoot } from 'react-dom/client';
import { App } from './App';

async function boot() {
  if (import.meta.env.DEV) {
    const { startMocks } = await import('./mocks/start');
    await startMocks();          // the await is the whole fix
  }
  createRoot(document.getElementById('root')!).render(<App />);
}

void boot();

Without the await, the first fetch of the first render races the worker and usually loses — producing an intermittent failure that looks like a flaky test rather than a setup bug.

The race that awaiting removes Two timelines. Without awaiting, the application renders immediately and its first fetch leaves before the worker has activated, so that request reaches the network while every later one is intercepted. With the await, worker activation completes first and every request including the first is intercepted. Without await — the first request escapes render first fetch — reaches the real network worker installing… activated — everything after this is intercepted With await — nothing escapes worker installing… render first fetch — intercepted, like every other The escaped request is usually a session or config call, which is why the symptom is "auth is weird" rather than "mocks are broken".

Verification

# The worker is served as JavaScript at the expected URL
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
  http://localhost:5173/mockServiceWorker.js
# → 200 text/javascript

# The committed worker matches the installed MSW version
npx msw init public/ --save && git diff --exit-code public/mockServiceWorker.js \
  || echo 'worker was stale — commit the regenerated file'

In the browser, DevTools → Application → Service Workers should show one activated worker whose scope covers the API paths. Two workers listed means an old registration survives; unregister it there and hard-reload.

Gotchas and edge cases

  • A stale worker survives a normal reload. Service workers persist across refreshes by design, so an old script keeps serving after you fix the file. Use DevTools’ “Update on reload” while debugging this, or unregister explicitly — otherwise you will fix the problem and see no change.

  • The preview build has no mocks even when the dev server does. import.meta.env.DEV is false in vite preview, so the gate skips startup. If mocks are wanted there, gate on an explicit variable such as VITE_ENABLE_MOCKS instead, as swapping mock and live APIs with env vars sets out.

  • Service workers need a secure context. They work on localhost and over HTTPS, and nowhere else — so accessing the dev server over a LAN IP such as http://192.168.1.20:5173 fails registration with no useful message. Use localhost, a .localhost hostname, or configure the dev server with HTTPS.


Confirming the worker is actually healthy Three checks that together prove the worker is doing its job: the URL returns JavaScript, exactly one worker is activated with a covering scope, and its version matches the installed MSW. Any two of the three passing is not enough. The URL returns JavaScript status and content type together an HTML fallback returns 200 One activated worker, covering scope in DevTools, Application panel two means an old registration survives Version matches the library MSW warns when it does not re-run init after every upgrade The middle check catches the case where everything is configured correctly and a stale registration is still serving.

Committing the worker file

One recurring cause of this problem deserves its own note: the generated worker being gitignored.

It looks like a build artefact — it is generated by a command, it is not hand-written, and generated files usually do not belong in a repository. But it is a build input: nothing regenerates it during a build, so a fresh clone with no worker file produces exactly the 404 this page is about, for every new contributor.

Commit it, and re-run the init command as part of any MSW upgrade so the committed copy stays in step with the installed library.

← Back to Mock Service Worker (MSW) Setup