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.
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.200withcontent-type: text/html— Vite’s SPA fallback answered. The URL is wrong, not the file. Go to step 3.200withcontent-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.
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.DEVis false invite preview, so the gate skips startup. If mocks are wanted there, gate on an explicit variable such asVITE_ENABLE_MOCKSinstead, as swapping mock and live APIs with env vars sets out. -
Service workers need a secure context. They work on
localhostand over HTTPS, and nowhere else — so accessing the dev server over a LAN IP such ashttp://192.168.1.20:5173fails registration with no useful message. Uselocalhost, a.localhosthostname, or configure the dev server with HTTPS.
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.
Related
- How to Configure MSW for Next.js Apps — the same setup in a framework with a server runtime as well
- Sharing MSW Handlers Between Browser and Node — the entry-point split this startup path depends on
- Mock Service Worker (MSW) Setup — the full installation and wiring guide
← Back to Mock Service Worker (MSW) Setup