DEV Community

Cover image for How to Add Offline Support to Your Web App (without hand-writing a service worker)
Abdulfetah Suudi
Abdulfetah Suudi

Posted on

How to Add Offline Support to Your Web App (without hand-writing a service worker)

The full walkthrough: one config file, an auditable vanilla-JS service worker + client runtime generated for you, zero runtime dependencies — and a live demo you can test offline right now.


Every serious web app eventually needs to work when the network doesn't. And every time, the work is the same: hand-writing a service worker, wired caching strategies, an offline fallback page, and — before long — auth handling and a queue for mutations that happened while offline.

A while back I got tired of re-writing that boilerplate for every project, in every framework. So I built Swoff: a config-driven generator that writes the service worker and client runtime into your repo as plain, auditable vanilla JavaScript. No runtime library injected into your bundle. MIT-licensed. If your stack has a fetch event, it works.

In this post I'll walk through the whole thing: init, generate, build step, inject — and show you a working offline-first site to test against.

The one-command promise

npx @swoff/cli init && npx @swoff/cli generate
Enter fullscreen mode Exit fullscreen mode

That's the whole setup. The CLI:

  1. init — interactive wizard (framework auto-detected, output directory, navigation mode, caching strategy, features), or --yes for defaults. Writes swoff.config.json.
  2. generate — writes the swoff/ runtime: the service-worker template, the generator, the client injector (ESM + IIFE), storage/reset utilities, and TypeScript declarations. Feature-specific files appear only when you enable the feature.

A real swoff.config.json

Here's what a typical config looks like (truncated to the interesting parts):

{
  "build": {
    "swOutput": "dist",
    "swoffPath": "dist/swoff",
    "swUrl": "/swoff.sw.js"
  },
  "navigation": {
    "mode": "spa",
    "fallback": "/offline"
  },
  "features": {
    "caching": {
      "enabled": true,
      "strategy": {
        "default": "cache-first",
        "patterns": {
          "/api/*": "stale-while-revalidate",
          "/api/checkout": "network-first"
        }
      }
    },
    "mutationQueue": { "enabled": true },
    "auth": { "enabled": true },
    "pushNotifications": { "enabled": true },
    "pwa": { "enabled": true }
  }
}
Enter fullscreen mode Exit fullscreen mode

That single config drives a lot. One note on strategy naming — Swoff supports 6 caching strategies:

  • cache-first — serve cache, update in the background
  • network-first — try network, fall back to cache on failure (great for navigation)
  • stale-while-revalidate — serve stale instantly, refresh from network
  • cache-only / network-only — the two extremes, when you want full control
  • reactive — serve cache as "fresh" for staleTime seconds, then serve cached and trigger a background refresh; add a refetchInterval for periodic background refetch

Strategies resolve in 3 tiers (per-request header → per-route patternsdefault), so you can pin one hot route and leave everything else on a sane default.

Adding the build step

The generator embeds your latest assets into the service worker, so it must run after every build. Simplest version — append to your build script:

{
  "scripts": {
    "build": "vite build && node swoff/sw/generator.mjs"
  }
}
Enter fullscreen mode Exit fullscreen mode

(Or run node swoff/sw/generator.mjs manually whenever you ship.)

Including the injector (bundler or not)

This is where Swoff really differs from framework-locked plugins — pick your shape:

Bundler frameworks (React, Vue, Svelte, HTMX, Solid; Next.js, Nuxt, SvelteKit, TanStack Start, Astro):

import { initServiceWorker } from "./swoff/client-injector";

initServiceWorker();
Enter fullscreen mode Exit fullscreen mode

No-bundler projects (Go, Laravel, Rails, Django, Flask, plain HTML/JS — no Node runtime needed in prod):

<script src="/swoff/client-injector.bundle.js"></script>
Enter fullscreen mode Exit fullscreen mode

The IIFE bundle self-initializes, so a plain <script> tag is all you need.

Verifying it works

Open your deployed site, then DevTools → Application → Service Workers. You should see the Swoff SW activated and running.

Head to Cache Storage and you'll find structured caches (e.g. swoff-precache, swoff-runtime, swoff-runtime-html). Finally, switch DevTools Network to Offline and reload — the site serves from the precache, with navigation falling back to your offline route when a page isn't cached yet.

The living demo

The best part is that this toolkit dogfoods itself:

https://swoff.space — the Swoff docs site — runs offline-first on Swoff. Its service worker is generated by the tool and precaches the entire site including an /offline route. Open it, throttle your network to something unflattering (or go fully offline), and the whole site keeps working. Refresh. Navigate. Read.

That's not a screenshot. It's the running product.

What else runs on this (feature surface)

  • Offline mutation queue — queue writes while offline, flush via background sync when you're back
  • Auth — token storage, 401 interception, refresh-before-request, offline auth state
  • GraphQL — caching + server push
  • Push notifications — subscribe/unsubscribe, notification handling, cleanup
  • PWA install — installability, splash, theme
  • Tag-based invalidation — evict cached assets by tag instead of nuking the whole cache

All of it generated as plain code you can read, edit, and debug. You own every line.

Try it

npx @swoff/cli init && npx @swoff/cli generate
Enter fullscreen mode Exit fullscreen mode

If you've built offline-first apps by hand — or been burned by a hand-rolled service worker in production — I'd genuinely love to hear how your approach differs, and where Swoff falls short.


Bonus: the same repo also ships @swoff/assets — 50+ PWA assets (icons, adaptive, splash, head tags) from one image or a wordmark. Worth a look if the asset grind is your pain point.

Top comments (2)

Collapse
 
raknaos profile image
Raknaos

Generating the worker into the repo instead of shipping a runtime is the tradeoff I'd want to understand better: the file is auditable at build time, but it also becomes yours to maintain. What happens when you regenerate after a config change -- plain overwrite, or a diff you review? Our own generated files drifted silently for a month before we added a CI check that regenerates and fails on a non-empty diff.

The failure mode I'd be curious about is upgrade behaviour, not first install. A cached service worker from an older generated version keeps serving the old strategy until it is released, so a change to cache rules can sit invisible for a whole reload cycle. If the generator emits the cache names deterministically from the config, that solves it; if it uses a build timestamp, every deploy orphans the old cache instead.

Collapse
 
iamsuudi profile image
Abdulfetah Suudi

Whenever you change the config, you run generate command to generate. Always the template is visible for diff and the runtime build only appends assets paths.
The runtime sw generated changes on every build even if there is no config changes.