Offline First: PWA and Serwist Service Worker
Offline First: PWA and Serwist Service Worker
A journaling app that only works when you have signal is missing the point.
Stone Maps is about place. Places include mountaintops, underground stations, remote coastlines, and the inside of a stone building with thick walls. If you can only journal when you have connectivity, you can't journal in the places that most deserve it.
This is why Stone Maps is a PWA. It's installable on your home screen. It caches the shell of the app. And when you lose signal, it serves an offline page rather than a blank screen.
Serwist
Serwist is a fork of Workbox adapted for modern frameworks. The @serwist/next package wraps Next.js's build pipeline to compile the service worker at build time and register it in production.
The configuration is in next.config.ts:
import withSerwistInit from '@serwist/next';
const withSerwist = withSerwistInit({
swSrc: 'app/sw.ts', // our service worker source
swDest: 'public/sw.js', // compiled output
disable: process.env.NODE_ENV === 'development',
});
export default withSerwist(nextConfig);
The service worker is disabled in development. This is important — service workers cache aggressively, and a cached service worker in development causes mysterious stale-content bugs. You'd change code, refresh, and see the old version because the SW served it from cache. Disabling in dev means the service worker only runs when you test a production build locally or in production.
The Service Worker
The service worker itself is minimal:
import { defaultCache } from '@serwist/next/worker';
import { Serwist } from 'serwist';
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache,
fallbacks: {
entries: [
{
url: '/offline',
matcher({ request }) {
return request.destination === 'document';
},
},
],
},
});
serwist.addEventListeners();
self.__SW_MANIFEST is injected by Serwist at build time — it's the list of all static assets to precache (JS bundles, fonts, icons). On first load, the service worker fetches and caches all of these. On subsequent loads, they're served instantly from the cache.
skipWaiting: true and clientsClaim: true together mean the new service worker activates immediately on update rather than waiting for all tabs to close. This is the "aggressive update" strategy — users get new code fast, at the cost of potentially serving a mix of old and new assets for a brief moment. For a journaling app where you're mostly offline when you actually use it, this tradeoff is acceptable.
navigationPreload: true is a performance optimization: while the service worker boots, the browser starts fetching the navigation request in parallel. This eliminates the latency of waiting for the SW to initialize before the page can load.
The Offline Fallback
When a navigation request fails (no network, server unreachable), the service worker serves /offline instead of the browser's default error page. The /offline route is a static page — a gentle message that you're offline, with the app shell still rendered so it doesn't feel like a crash.
The matcher scopes this to request.destination === 'document' — navigation requests only. Images and API calls that fail offline don't get redirected to /offline; they fail silently or are handled by the app. Only actual page navigations get the fallback treatment.
What Works Offline
The app shell — header, navigation, loading states — works offline because it's precached. What doesn't work offline:
- New posts — creating a post requires hitting the API. There's no offline queue yet.
- Map data — the map tiles and post data come from the network. The map is blank offline.
- Emissary conversations — AI responses require network calls to OpenAI/Anthropic.
What you can do offline is open the app and read your cached journal entries (if they were loaded while you were online). The page-level HTML is cached; if you navigated to a post while you had signal, that post is available offline.
What's Missing: Offline Write Queue
The meaningful missing piece is offline writes. If you're at a mountaintop with no signal and you want to journal from there, you currently can't. The post form will fail at submission.
The right solution is an offline write queue: posts created without connectivity get stored in IndexedDB and synced when signal returns. This is standard PWA behavior but requires building a sync layer — a background sync registration, a conflict resolution strategy for optimistic IDs, and UI that shows "pending sync" state.
We haven't built this yet. For early access, the message has been: if you want to journal from somewhere without signal, draft it in your notes app and transfer it when you're back online. It's not good enough, and we know it.
Installation
Because Stone Maps is a PWA, it can be installed on iOS and Android as a home screen app. On iOS this requires Safari (Add to Home Screen from the share sheet). On Android, Chrome prompts automatically after a few visits.
Once installed, the app runs in standalone mode — no browser chrome, full screen, behaves like a native app. The icon and splash screen are configured in the web app manifest. The service worker runs in the background even when the app isn't open.
For a journaling app, the installed experience feels right. You open it the way you open a notebook — directly, not through a browser tab. That small shift in how you approach it changes how you use it.