Skip to content

Svelte-Specific PWA Patterns

Svelte’s store contract — any object with a subscribe method — is the perfect home for PWA lifecycle events. Events like going offline, a new SW becoming available, or a user dismissing an install prompt happen asynchronously and at unpredictable times. Stores make that state reactive and available anywhere in the component tree without prop drilling.

src/lib/stores/network.ts
import { readable } from 'svelte/store';
export const online = readable(
typeof navigator !== 'undefined' ? navigator.onLine : true,
(set) => {
const handleOnline = () => set(true);
const handleOffline = () => set(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}
);

Use it in any component with the $ auto-subscribe shorthand:

<script>
import { online } from '$lib/stores/network';
</script>
{#if !$online}
<div class="offline-banner">You are offline — showing cached content.</div>
{/if}

The typeof navigator !== 'undefined' guard is required because SvelteKit runs components on the server during SSR, where navigator does not exist.

The beforeinstallprompt event fires when the browser decides your PWA is installable. You must capture the event object — it is ephemeral — and call .prompt() on it later when the user interacts with your install button.

src/lib/stores/install-prompt.ts
import { writable } from 'svelte/store';
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
};
export const installPromptEvent = writable<BeforeInstallPromptEvent | null>(null);
if (typeof window !== 'undefined') {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // Prevent the mini-infobar
installPromptEvent.set(e as BeforeInstallPromptEvent);
});
}

A component that uses the store to show an install button:

src/lib/InstallButton.svelte
<script lang="ts">
import { installPromptEvent } from '$lib/stores/install-prompt';
async function install() {
if (!$installPromptEvent) return;
await $installPromptEvent.prompt();
const { outcome } = await $installPromptEvent.userChoice;
if (outcome === 'accepted') {
installPromptEvent.set(null); // hide the button after install
}
}
</script>
{#if $installPromptEvent}
<button on:click={install}>Install App</button>
{/if}

You can keep update state in a plain writable store so any part of your UI can react to it:

src/lib/stores/sw-update.ts
import { writable } from 'svelte/store';
export const updateAvailable = writable(false);
export let swRegistration: ServiceWorkerRegistration | null = null;
export async function initSW() {
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.register(
'/service-worker.js',
{ type: import.meta.env.DEV ? 'module' : 'classic' }
);
swRegistration = registration;
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
updateAvailable.set(true);
}
});
});
}
export function applyUpdate() {
swRegistration?.waiting?.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}

Wire the stores in src/routes/+layout.svelte:

src/routes/+layout.svelte
<script lang="ts">
import { onMount } from 'svelte';
import { initSW, updateAvailable, applyUpdate } from '$lib/stores/sw-update';
onMount(() => {
initSW();
});
</script>
<slot />
{#if $updateAvailable}
<div class="update-banner">
A new version is available.
<button on:click={applyUpdate}>Update now</button>
</div>
{/if}

Service workers only run in the browser. Two common SvelteKit pitfalls:

  1. navigator / window in store initializers. Always guard with typeof window !== 'undefined' or run the code inside onMount.
  2. adapter-static for fully offline apps. If you want users to open every page while offline, every route must be prerendered. Set export const prerender = true in your root +layout.js (or use adapter-static which forces prerendering). The SW then precaches all prerendered pages via the prerendered export from $service-worker.
// src/routes/+layout.js — opt entire app into prerendering
export const prerender = true;

With adapter-node or adapter-vercel, only routes you explicitly prerender are available offline. Dynamic server-rendered routes require a network connection.

Why must you call e.preventDefault() on the beforeinstallprompt event?
Why guard navigator.onLine with typeof navigator !== "undefined" in a store initializer?
Which SvelteKit adapter ensures every page is prerendered so the SW can precache it for offline use?
Where is the correct place to call initSW() in a SvelteKit app?