When developing an app on a local port, the ghost of previously developed app appears in the browser
I usually build apps with SvelteKit, and when I open http://localhost:5173/ and reload over and over, I sometimes see fragments of a completely different app — one that isn't even running right now — flickering in and out. It makes local development really hard to focus on.
The culprit is almost always a Service Worker (SW) from an older project that's still alive in the localhost:5173 origin scope.
Why It Happens
A SW is registered against an origin (scheme + host + port). On a dev machine, multiple apps usually share the same port like localhost:5173. So once you register a SW for one app, the next app you serve on the same port has its requests intercepted by that previous SW, which then returns cached responses from the old app.
A SW isn't unregistered by a normal reload, and a hard reload (Cmd + Shift + R) doesn't always remove it either. That's how it becomes a "ghost that refuses to disappear no matter how many times you reload."
How to Check
Open your browser's DevTools → Application tab → Service Workers. You'll see a list of SWs running on the current origin. If any of them don't belong to the app you're working on, that's your culprit.
While you're there, also peek at Application → Storage → Cache Storage to see what the SW has been caching.

How to Fix
Hit Unregister next to the offending SW on the Service Workers page, and the ghost is gone. For full hygiene, clear out any related Cache Storage entries too.
Auto-Disable the SW in SvelteKit Dev Mode
Unregistering manually every time gets old fast. It's much nicer to simply not register the SW in dev mode. If you also make it auto-strip any SW left over from a previous project, switching between apps becomes painless.
Create src/lib/registerServiceWorker.ts:
export function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
// Don't register the SW in dev mode.
// SWs persist at the origin scope (e.g. localhost:5173), so the cache
// gets polluted whenever you develop a different Vite app on the same port.
if (import.meta.env.DEV) {
// Strip any SW left over from a previous dev session
navigator.serviceWorker.getRegistrations().then((regs) => {
regs.forEach((reg) => reg.unregister());
});
if (typeof caches !== 'undefined') {
caches.keys().then((keys) => {
keys.forEach((key) => caches.delete(key));
});
}
return;
}
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.catch((err) => console.error('SW registration failed:', err));
});
}
Call it from +layout.svelte or +page.svelte inside onMount:
<script lang="ts">
import { onMount } from 'svelte';
import { registerServiceWorker } from '$lib/registerServiceWorker';
onMount(() => {
registerServiceWorker();
});
</script>
Key Points
- Branch on
import.meta.env.DEV: An env variable provided by Vite (the build tool SvelteKit uses internally). It'strueonly duringnpm run dev. - Unregister and clear caches in dev mode: A bare
returnisn't enough. If you ever switch from a prod build back to dev, the SW is still camped at that origin. Explicitly stripping it out keeps every dev session clean from the moment you switch apps.
When You Decide to Drop PWA Support
If you change your mind about PWA and just delete the SW file outright, the SW registered in users' browsers stays alive, and the old caches keep getting served forever.
The safe approach is to leave the SW file in place but replace its contents with a stub that unregisters itself. Browsers check for SW updates on every navigation, so the next time the user visits, the stub version activates, and both the cache and the registration get cleaned up automatically.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
await self.registration.unregister();
const clients = await self.clients.matchAll({
includeUncontrolled: true,
type: 'window',
});
clients.forEach((client) => client.navigate(client.url));
})()
);
});
Make sure to pass { includeUncontrolled: true, type: 'window' } to matchAll. The default arguments only return clients this new SW is already controlling, so tabs still attached to the old SW would be missed otherwise.
Summary
- If something on the page won't go away after a reload, suspect a Service Worker
- Check DevTools → Application → Service Workers, and Unregister if needed
- Configure your project to skip SW registration in dev mode from the start
- When dropping PWA support, replace the SW with a self-unregistering stub before deleting it
We look forward to discussing your development needs.