This is part 1 of FieldKit, a series where I build one real Progressive Web App and use it to dig into what modern PWAs can actually do — grouping related capabilities into an article as they fit, so the series runs as long as there's something worth showing. The app is a field-notes tool: you jot geotagged notes, photos, and audio out in the world — often with no signal — and it all syncs when you're back online. Everything is open source (on GitHub). I've spent years shipping desktop apps in Electron, so along the way I'll compare how each capability feels on the web versus that native-ish world.
Why "offline-first" is the foundation, not a feature
Most PWA tutorials bolt offline support on at the end. I think that's backwards. If your app assumes the network is always there, you end up retrofitting loading states, error handling, and data persistence into code that was never designed for them.
FieldKit is the perfect forcing function: the whole point is using it where there's no connection — a trail, a basement, a plane, the subway. So offline isn't a nice-to-have, it's the first thing we build. Get this right and every later feature (camera, geolocation, push) slots into a model that already expects the network to come and go.
There are three pieces to a genuinely offline-first app, and people often confuse them:
- The app shell loads offline — your HTML, CSS, and JS are cached so the app opens with no network. (Service worker.)
- User data persists offline — notes the user creates are stored locally, not lost on refresh. (IndexedDB.)
- Changes reconcile when you're back online — queued work flushes automatically. (Background Sync, with a fallback.) Miss any one of these and "offline support" is an illusion. Let's build all three.
Piece 1: caching the app shell with a service worker
A service worker is a script the browser runs in the background, separate from your page. Its superpower is the fetch event: it can intercept every network request your app makes and decide how to answer — from the network, from a cache, or some mix.
We register it on boot:
if ("serviceWorker" in navigator) {
await navigator.serviceWorker.register("/sw.js");
}
The interesting part is the strategy. FieldKit uses two:
- Cache-first for the app shell (HTML/CSS/JS/icons). These rarely change between deploys, and we want instant loads, so we serve from cache and only hit the network on a miss.
- Network-first for everything else. We want fresh data when online, but fall back to whatever we cached last when offline. Here's the install step, where we pre-cache the shell:
const CACHE_VERSION = "fieldkit-v1";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const SHELL_ASSETS = [
"/", "/index.html", "/css/styles.css",
"/js/app.js", "/js/db.js", "/manifest.webmanifest",
"/icons/icon-192.png", "/icons/icon-512.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
);
self.skipWaiting();
});
And the fetch handler that picks a strategy per request:
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return; // never cache mutations
// Navigations: network-first, fall back to the cached shell offline.
if (request.mode === "navigate") {
event.respondWith(fetch(request).catch(() => caches.match("/index.html")));
return;
}
const url = new URL(request.url);
if (SHELL_ASSETS.includes(url.pathname)) {
// Shell assets: cache-first.
event.respondWith(
caches.match(request).then((cached) => cached || fetch(request))
);
return;
}
// Everything else: network-first, cache as we go.
event.respondWith(
fetch(request)
.then((response) => {
const copy = response.clone();
caches.open(`${CACHE_VERSION}-runtime`).then((c) => c.put(request, copy));
return response;
})
.catch(() => caches.match(request))
);
});
The gotcha everyone hits: stale caches
The single most common service-worker bug is shipping a new version and watching users stay stuck on the old one. The fix is a versioned cache name plus an activate handler that deletes anything that doesn't match the current version:
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => !k.startsWith(CACHE_VERSION)).map((k) => caches.delete(k))
)
).then(() => self.clients.claim())
);
});
Bump CACHE_VERSION on every shell change and old caches get swept away. skipWaiting() + clients.claim() make the new worker take control immediately rather than waiting for every tab to close. (In a bigger app you'd want to prompt the user before reloading under them — instant swap can be jarring mid-edit — but for FieldKit's scope it's fine.)
Piece 2: persisting user data with IndexedDB
localStorage is tempting and wrong here: it's synchronous (it blocks the main thread), it's string-only, and it caps out around 5 MB. We're going to store notes with attached media, so we want IndexedDB — asynchronous, structured, and roomy.
I deliberately use raw IndexedDB rather than a wrapper library so you can see exactly what's happening:
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open("fieldkit", 1);
req.onupgradeneeded = () => {
const db = req.result;
const store = db.createObjectStore("entries", { keyPath: "id" });
store.createIndex("createdAt", "createdAt");
store.createIndex("synced", "synced");
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function addEntry(entry) {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction("entries", "readwrite")
.objectStore("entries").add(entry);
req.onsuccess = () => resolve(entry);
req.onerror = () => reject(req.error);
});
}
Every note carries a synced flag. New notes start synced: false — that's our queue.
const entry = {
id: crypto.randomUUID(),
text,
createdAt: Date.now(),
synced: false,
};
await addEntry(entry);
Because the data lives in IndexedDB and the shell is cached, the app now fully works on a dead connection: open it, add notes, refresh, close and reopen — nothing is lost.
Piece 3: reconciling with Background Sync
Now the magic part. When the user adds a note offline, we want it to upload itself the moment connectivity returns — even if the app is in the background or closed.
That's the Background Sync API.
You register a sync from the page:
async function requestSync() {
if ("serviceWorker" in navigator && "SyncManager" in window) {
const reg = await navigator.serviceWorker.ready;
try {
await reg.sync.register("sync-entries");
return;
} catch { /* fall through */ }
}
// Fallback (e.g. Safari): just try now if we happen to be online.
if (navigator.onLine) flushQueue();
}
The browser fires a sync event in the service worker when it decides the network is healthy. The worker then tells any open clients to flush the queue:
self.addEventListener("sync", (event) => {
if (event.tag === "sync-entries") {
event.waitUntil(notifyClientsToSync());
}
});
In FieldKit the "upload" is faked so the demo runs without a backend — swap fakeUpload() for a real fetch() and you're done.
The honest browser-support picture
This is where being straight with readers matters more than a clean demo:
- Service workers + Cache API + IndexedDB: supported everywhere that matters, including Safari and Firefox. This is solid, ship-it technology.
-
Background Sync (
SyncManager): Chromium only. As of this writing Safari and Firefox don't support it. That's why FieldKit always has a fallback path — we listen for theonlineevent and flush the queue manually:
window.addEventListener("online", () => {
if (navigator.onLine) flushQueue();
});
So on Chrome/Edge/Android you get true background reconciliation; on Safari/Firefox you get "sync when the app is open and regains connectivity." Both are fine — the key is to design for the weaker guarantee and treat Background Sync as an enhancement. Always check caniuse.com before you rely on it, because support does shift.
How this compares to Electron
Coming from desktop, the web's offline story is genuinely different in feel:
-
Electron ships the entire app shell on disk by definition — "offline app load" is free, you never think about it. The web makes you earn it with a service worker, but you get automatic, incremental updates in return (no installer to re-download). The service-worker cache is essentially you re-implementing what Electron gets from the filesystem — except here the browser handles versioning and cleanup for you once you wire up the
activatestep. - For local data, an Electron app usually reaches for the filesystem or a bundled SQLite. On the web, IndexedDB is the equivalent durable store — more ceremony than
fs.writeFile, but it's sandboxed, quota-managed, and works identically across every browser without you shipping a database engine. The mental model that ports across both worlds: treat the local store as the source of truth and the network as an eventually-consistent sync target. Build that way and offline stops being scary — on the desktop or on the web.
Try it
Clone the repo, serve it over HTTPS (or localhost), open DevTools → Application → Service Workers, tick "Offline," and keep adding notes. They persist. Untick "Offline" and watch them flush.
git clone https://github.com/JohnJunior/FieldKit.git
cd FieldKit
npx serve . # any static server over localhost works
Next week (FieldKit #2): making the app installable — the manifest, the beforeinstallprompt dance on Chromium, and why iOS makes you do it by hand.


Top comments (0)