DEV Community

Cover image for Can a web app really work with zero network? Let's build one (FieldKit #1)
Oleksandr Trukhnii
Oleksandr Trukhnii

Posted on

Can a web app really work with zero network? Let's build one (FieldKit #1)


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:

  1. The app shell loads offline — your HTML, CSS, and JS are cached so the app opens with no network. (Service worker.)
  2. User data persists offline — notes the user creates are stored locally, not lost on refresh. (IndexedDB.)
  3. 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");
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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))
  );
});
Enter fullscreen mode Exit fullscreen mode

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())
  );
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Note added while offline

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();
}
Enter fullscreen mode Exit fullscreen mode

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());
  }
});
Enter fullscreen mode Exit fullscreen mode

In FieldKit the "upload" is faked so the demo runs without a backend — swap fakeUpload() for a real fetch() and you're done.

Uploaded note after return online

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 the online event and flush the queue manually:
window.addEventListener("online", () => {
  if (navigator.onLine) flushQueue();
});
Enter fullscreen mode Exit fullscreen mode

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 activate step.
  • 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
Enter fullscreen mode Exit fullscreen mode

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)