Skip to main content

Service workers

Service workers act as proxy servers that handle network requests inside your app. This makes it possible to make your app work offline, but even if you don’t need offline support (or can’t realistically implement it because of the type of app you’re building), it’s often worth using service workers to speed up navigation by precaching your built JS and CSS.

In SvelteKit, if you have a src/service-worker.js file (or src/service-worker/index.js) it will be bundled and automatically registered.

Inside the service worker

Inside the service worker you have access to the $service-worker module, which provides you with the paths to all static assets, build files and prerendered pages. You’re also provided with an app version string, which you can use for creating a unique cache name, and the deployment’s base path. If your Vite config specifies define (used for global variable replacements), this will be applied to service workers as well as your server/client builds.

The following example caches the built app and any files in static eagerly, and caches all other requests as they happen. This would make each page work offline once visited.

src/service-worker
// Disables access to DOM typings like `HTMLElement` which are not available
// inside a service worker and instantiates the correct globals
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
// Ensures that the `$service-worker` import has proper type definitions/// <reference types="@sveltejs/kit" />// Only necessary if you have an import from `$env/static/public`/// <reference types="../.svelte-kit/ambient.d.ts" />
import { build, files, version } from '$service-worker';
// This gives `self` the correct typesconst self = /** @type {ServiceWorkerGlobalScope} */ (/** @type {unknown} */ (globalThis.self));
// Create a unique cache name for this deploymentconst CACHE = `cache-${version}`;

const ASSETS = [
	...build, // the app itself
	...files  // everything in `static`
];

self.addEventListener('install', (event) => {
	// Create a new cache and add all files to it	async function addFilesToCache() {
		const cache = await caches.open(CACHE);
		await cache.addAll(ASSETS);
	}

	event.waitUntil(addFilesToCache());
});

self.addEventListener('activate', (event) => {
	// Remove previous cached data from disk	async function deleteOldCaches() {
		for (const key of await caches.keys()) {
			if (key !== CACHE) await caches.delete(key);
		}
	}

	event.waitUntil(deleteOldCaches());
});

self.addEventListener('fetch', (event) => {
	// ignore POST requests etc	if (event.request.method !== 'GET') return;

	async function respond() {
		const url = new URL(event.request.url);
		const cache = await caches.open(CACHE);
		// `build`/`files` can always be served from the cache		if (ASSETS.includes(url.pathname)) {
			const response = await cache.match(url.pathname);

			if (response) {
				return response;
			}
		}
		// for everything else, try the network first, but		// fall back to the cache if we're offline		try {
			const response = await fetch(event.request);
			// if we're offline, fetch can return a value that is not a Response			// instead of throwing - and we can't pass this non-Response to respondWith			if (!(response instanceof Response)) {
				throw new Error('invalid response from fetch');
			}

			if (response.status === 200 && !response.headers.get('cache-control')?.includes('no-store')) {
				cache.put(event.request, response.clone());
			}

			return response;
		} catch (err) {
			const response = await cache.match(event.request);

			if (response) {
				return response;
			}
			// if there's no cache, then just error out			// as there is nothing we can do to respond to this request			throw err;
		}
	}

	event.respondWith(respond());
});
// Disables access to DOM typings like `HTMLElement` which are not available
// inside a service worker and instantiates the correct globals
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
// Ensures that the `$service-worker` import has proper type definitions/// <reference types="@sveltejs/kit" />// Only necessary if you have an import from `$env/static/public`/// <reference types="../.svelte-kit/ambient.d.ts" />
import { build, files, version } from '$service-worker';
// This gives `self` the correct typesconst self = globalThis.self as unknown as ServiceWorkerGlobalScope;
// Create a unique cache name for this deploymentconst CACHE = `cache-${version}`;

const ASSETS = [
	...build, // the app itself
	...files  // everything in `static`
];

self.addEventListener('install', (event) => {
	// Create a new cache and add all files to it	async function addFilesToCache() {
		const cache = await caches.open(CACHE);
		await cache.addAll(ASSETS);
	}

	event.waitUntil(addFilesToCache());
});

self.addEventListener('activate', (event) => {
	// Remove previous cached data from disk	async function deleteOldCaches() {
		for (const key of await caches.keys()) {
			if (key !== CACHE) await caches.delete(key);
		}
	}

	event.waitUntil(deleteOldCaches());
});

self.addEventListener('fetch', (event) => {
	// ignore POST requests etc	if (event.request.method !== 'GET') return;

	async function respond() {
		const url = new URL(event.request.url);
		const cache = await caches.open(CACHE);
		// `build`/`files` can always be served from the cache		if (ASSETS.includes(url.pathname)) {
			const response = await cache.match(url.pathname);

			if (response) {
				return response;
			}
		}
		// for everything else, try the network first, but		// fall back to the cache if we're offline		try {
			const response = await fetch(event.request);
			// if we're offline, fetch can return a value that is not a Response			// instead of throwing - and we can't pass this non-Response to respondWith			if (!(response instanceof Response)) {
				throw new Error('invalid response from fetch');
			}

			if (response.status === 200 && !response.headers.get('cache-control')?.includes('no-store')) {
				cache.put(event.request, response.clone());
			}

			return response;
		} catch (err) {
			const response = await cache.match(event.request);

			if (response) {
				return response;
			}
			// if there's no cache, then just error out			// as there is nothing we can do to respond to this request			throw err;
		}
	}

	event.respondWith(respond());
});

Be careful when caching! In some cases, stale data might be worse than data that’s unavailable while offline. Since browsers will empty caches if they get too full, you should also be careful about caching large assets like video files.

build and prerendered are empty arrays during development

Manual registration

You can disable automatic registration if you need to register the service worker with your own logic. The default registration looks something like this:

import { dev } from '$app/environment';

if ('serviceWorker' in navigator) {
	addEventListener('load', function () {
		navigator.serviceWorker.register('./path/to/service-worker.js', {
			type: dev ? 'module' : 'classic'
		});
	});
}

The service worker is bundled for production, but not during development.

Updating the service worker

Browsers check for an updated service worker when a full-page navigation happens within its scope, and after functional events such as push and sync. Client-side navigations are neither, so navigating around your app will not by itself cause a new deployment’s service worker to be picked up.

SvelteKit calls registration.update() only as part of error recovery — if a route module fails to load or a navigation results in an error status, and version polling detects that the app has been redeployed, the service worker is updated before SvelteKit falls back to a full-page navigation.

If you want new deployments to be picked up more eagerly, you can trigger an update check yourself — for example on every client-side navigation, in your root layout:

import { afterNavigate } from '$app/navigation';

afterNavigate(async () => {
	if ('serviceWorker' in navigator) {
		const registration = await navigator.serviceWorker.getRegistration();
		await registration?.update();
	}
});

This will not cause the new service worker (if there is one) to take over the existing page immediately — instead, it will be installed in the background and take over as soon as the number of tabs managed by the existing service worker drops to zero.

Other solutions

SvelteKit’s service worker implementation is designed to be easy to work with and is probably a good solution for most users. However, outside of SvelteKit, many PWA applications leverage the Workbox library. If you’re used to using Workbox you may prefer Vite PWA plugin.

References

For more general information on service workers, we recommend the MDN web docs.

Edit this page on GitHub llms.txt