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
That's the whole setup. The CLI:
-
init— interactive wizard (framework auto-detected, output directory, navigation mode, caching strategy, features), or--yesfor defaults. Writesswoff.config.json. -
generate— writes theswoff/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 }
}
}
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" forstaleTimeseconds, then serve cached and trigger a background refresh; add arefetchIntervalfor periodic background refetch
Strategies resolve in 3 tiers (per-request header → per-route patterns → default), 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"
}
}
(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();
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>
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
/offlineroute. 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
- Docs (the running demo): https://swoff.space
- npm: https://www.npmjs.com/package/@swoff/cli
- Source: https://github.com/iamsuudi/swoff
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)
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.
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.