Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 055 / 100

Service workers in SvelteKit: what developers should know

Offline support and caching.

Expona Admin8 min read

Introduction

Most web applications assume a stable internet connection, but the reality is messier. Users lose signal in subways, cafés serve unreliable WiFi, and corporate networks throttle aggressively. Service workers in SvelteKit give you what developers should know to handle these real-world conditions: a way to intercept network requests, cache responses strategically, and serve your app even when the network fails. This article walks through the patterns we've found work best in production across e-commerce, SaaS, and travel platforms.

Understanding src/service-worker.ts and automatic builds

SvelteKit treats service workers differently from regular application code. When you create a file at src/service-worker.ts, the framework automatically builds it as a standalone script that runs in the service worker scope—not alongside your app's main JavaScript bundle. This separation is intentional: service workers need their own lifecycle and memory context to intercept all network traffic reliably.

The automatic build process means you don't need custom webpack or Vite configuration. SvelteKit compiles your service worker, inlines any imports that can be bundled, and outputs it to the build directory. During development, changes to your service worker trigger a rebuild, though you may need to manually unregister old workers in DevTools to see changes immediately.

Here's a minimal service worker that logs when it installs:

typescript
declare const self: ServiceWorkerGlobalScope;

self.addEventListener('install', (event) => {
  console.log('Service worker installing...');
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  console.log('Service worker activated');
  event.waitUntil(clients.claim());
});

self.addEventListener('fetch', (event) => {
  // We'll add fetch handling in the next sections
});

export {};

Notice the declare const self: ServiceWorkerGlobalScope line. This gives TypeScript the correct types for the service worker API, preventing type errors when you access self.caches or other global objects that don't exist in regular browser JavaScript.

The skipWaiting() call tells the new service worker to take over immediately rather than waiting for the current one to become unused. In combination with clients.claim() in the activate handler, this ensures updates roll out faster—though you'll want to notify the user that a new version is available.

Cache build assets for reliable offline access

Your app's HTML, CSS, and JavaScript bundle are deterministic: once built, they don't change until you deploy again. This makes them perfect for aggressive caching. The strategy we recommend is precaching: during the install event, download and store critical assets so they're available immediately, even on first visit after a network failure.

SvelteKit injects a list of build assets into your service worker automatically via the $service-worker alias. Here's how to precache them:

typescript
import { build, files, version } from '$service-worker';

declare const self: ServiceWorkerGlobalScope;

const CACHE = `cache-${version}`;
const ASSETS = [...build, ...files];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches
      .open(CACHE)
      .then((cache) => cache.addAll(ASSETS))
      .then(() => self.skipWaiting())
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys
          .filter((key) => key !== CACHE)
          .map((key) => caches.delete(key))
      );
    })
  );
  return self.claim();
});

export {};

The $service-worker module provides three key exports: build (app entry point), files (static assets from static/), and version (a hash that changes per deployment). By versioning the cache with the build hash, old caches automatically clean up when you deploy—no stale assets hanging around.

Precaching build assets means your HTML shell, CSS, and JavaScript load instantly from the cache. Users see your app immediately, even offline. The tradeoff is that every asset must be small enough to cache; this isn't a solution for multi-megabyte video files.

Network-first for dynamic data with fallback

Static assets work well with cache-first strategies, but API responses change constantly. For data fetches—product details, user profiles, real-time feeds—use a network-first approach: try the network first, fall back to a cached version if the request fails, and update the cache when the network succeeds.

Here's a practical implementation for an e-commerce app where we cache API responses but prioritize fresh data:

typescript
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Precached assets: cache-only
  if (ASSETS.includes(url.pathname)) {
    return event.respondWith(caches.match(event.request));
  }

  // API calls: network-first
  if (url.pathname.startsWith('/api/')) {
    return event.respondWith(
      fetch(event.request)
        .then((response) => {
          if (!response || response.status !== 200) {
            return response;
          }
          // Clone the response before caching
          const copy = response.clone();
          caches.open('api-cache-v1').then((cache) => {
            cache.put(event.request, copy);
          });
          return response;
        })
        .catch(() => {
          return caches.match(event.request);
        })
    );
  }

  // Default: network-only for uncached routes
  return event.respondWith(fetch(event.request));
});

export {};

This pattern separates concerns: precached assets are always served from cache (fastest), API calls try the network and cache the response as a fallback, and everything else hits the network normally. The response.clone() call is crucial—the Response object is a stream and can only be read once, so we read it from the network and cache a copy simultaneously.

In a real SaaS application, you might be more selective. Cache only GET requests, avoid caching failed responses, and add a timestamp to invalidate old cached data after a few hours:

typescript
const CACHE_TTL = 3600000; // 1 hour in milliseconds

self.addEventListener('fetch', (event) => {
  if (event.request.method !== 'GET') {
    return event.respondWith(fetch(event.request));
  }

  const url = new URL(event.request.url);
  if (url.pathname.startsWith('/api/')) {
    return event.respondWith(
      fetch(event.request)
        .then((response) => {
          if (response.status === 200) {
            const copy = response.clone();
            caches.open('api-cache-v1').then((cache) => {
              const headers = new Headers(copy.headers);
              headers.set('sw-cached-at', Date.now().toString());
              cache.put(
                event.request,
                new Response(copy.body, {
                  status: copy.status,
                  statusText: copy.statusText,
                  headers
                })
              );
            });
          }
          return response;
        })
        .catch(async () => {
          const cached = await caches.match(event.request);
          if (cached) {
            const cachedAt = parseInt(
              cached.headers.get('sw-cached-at') || '0'
            );
            if (Date.now() - cachedAt < CACHE_TTL) {
              return cached;
            }
          }
          return new Response('Offline and cache expired', {
            status: 503
          });
        })
    );
  }

  return event.respondWith(fetch(event.request));
});

This is closer to production-grade: it respects HTTP method, only caches successful responses, tracks cache age, and returns a meaningful error when both network and cache fail.

Handle updates gracefully and notify users

Service workers persist across page reloads. When you deploy new code, the new service worker installs but doesn't activate until the old one is unused. Meanwhile, users still run the old version. A good update strategy notifies the user and lets them reload.

In SvelteKit, listen for the update event fired by the framework when a new service worker is ready:

typescript
// In a +layout.svelte or similar component
import { onMount } from 'svelte';

let updateAvailable = false;

onMount(async () => {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready.then((registration) => {
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker?.addEventListener('statechange', () => {
          if (newWorker.state === 'activated') {
            updateAvailable = true;
          }
        });
      });
    });
  }
});

function handleUpdate() {
  window.location.reload();
}

When updateAvailable is true, show a banner or toast notification giving users control. This respects their current session—no surprise reloads mid-transaction.

For a ride-hailing or payment platform, you might want to force an update if a critical security fix is deployed. Add a version check in your app and prompt aggressively:

typescript
const REQUIRED_VERSION = '2.1.0';
let currentVersion = '2.0.5';

if (updateAvailable && isSecurityCritical(REQUIRED_VERSION, currentVersion)) {
  updateAvailable = true;
  forceReload = true;
}

The key principle: users should retain control of their experience, but your app should communicate the benefits of staying current.

Common mistakes

  • Forgetting to clone responses. The Response body is a stream. Not cloning before caching means you read it once and it's gone. Always use response.clone() before storing.
  • Caching POST or mutation requests. Service workers don't discriminate by default. Cache only safe requests (GET, HEAD). Caching form submissions or API mutations leads to stale or duplicated data.
  • Not cleaning up old caches. The activate event is your chance to delete versioned caches from old deployments. Miss this and users accumulate gigabytes of cached data over time.
  • Serving cached errors indefinitely. If you cache an HTTP 500 response, users see the error forever offline. Cache only successful responses, or at least add expiration logic.
  • Blocking install or activate with slow operations. Use event.waitUntil() sparingly. If cache.addAll() times out, installation fails and users get no offline support at all. Precache only critical assets.
  • Not testing offline. Chrome DevTools has an Offline checkbox in the Network tab. Also test on real networks—cafés, subways, remote offices—where latency and packet loss behave differently.

Key takeaways

  • SvelteKit automatically builds src/service-worker.ts as a standalone script. Use TypeScript imports and the $service-worker alias for build assets.
  • Precache static assets (HTML, CSS, JS) during install to guarantee offline access. Version the cache with the build hash so old caches clean up automatically.
  • Use network-first for API calls: fetch from the network, cache the response, fall back to cache if offline. Only cache successful GET requests.
  • Always clone responses before caching, and add expiration logic to prevent stale data from persisting indefinitely.
  • Notify users when updates are ready and let them choose to reload. Avoid surprise reloads mid-session.
  • Test offline behavior early and often. Service workers hide complexity; you'll find edge cases only by actually disconnecting and testing under real-world conditions.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles