Built for developers who care about building PWAs that don't suck, healthcare teams, and anyone obsessed with offline-first Hard-won lessons from building Pain Tracker's progressive web app
Why This Even Matters
You know what's frustrating? Trying to log your pain levels in a hospital basement where cell service goes to die. Or being stuck on some rural road with a flare-up, desperately wanting to record what's happening, but your "smart" health app just spins endlessly because it can't phone home.
I've watched too many healthcare apps fail their users at the worst possible moments. When someone's dealing with chronic pain, the last thing they need is technology adding to their stress. So when we built Pain Tracker's PWA, offline-first wasn't just a nice-to-have—it was the whole point.
We ended up supporting 44+ anatomical locations and 19+ symptom types, all available even when your phone thinks the internet doesn't exist. This post walks through how we actually pulled it off, because honestly, most "offline-first" tutorials are pretty useless when you're dealing with real healthcare data.
I'm going to break this down into four main chunks: how we store data (spoiler: we use two databases), the service worker magic that keeps everything running, background sync that catches up when you're connected again, and the PWA manager that makes it all feel seamless. Each section has actual code from production, because generic examples don't help anyone build real things.
The Double Database Thing
This sounds overcomplicated at first, but hear me out. We store everything twice—once in IndexedDB for the long haul, and once in localStorage for lightning-fast reads. It's like keeping your passport in a safe but also having a photocopy in your wallet.
Why Two Databases Don't Suck
IndexedDB is our single source of truth. It holds pain entries, activity logs, emergency contacts, user settings, and a queue of stuff waiting to sync. We've got indexes for data type, timestamps, and sync status so we can actually find things without scanning everything.
But here's where it gets clever—we also write settings straight to localStorage because it's ridiculously fast. When someone opens the app, boom, their preferences are right there. No waiting around for IndexedDB to wake up and remember what it was doing.
The really neat trick is our "virtual tables" approach. Instead of real database tables that require migrations every time you change something, we just use prefixed keys like table:pain-entries:123 or table:settings:theme. Need a new data type? Pick a new prefix. Done. No migrations, no version bumps, no hair-pulling.
// This simple pattern saves so much headache
private makeTableKey(tableName: string, id: string) {
return `table:${tableName}:${id}`; // Clean, simple, scalable
}
// Fast reads that always work
async getItem(key: string) {
const cached = localStorage.getItem(key);
if (cached !== null) return JSON.parse(cached);
// Fallback to IndexedDB if localStorage fails
const settings = await this.storage.getData('settings');
const setting = settings.find(s => s.data?.key === key);
return setting?.data?.value ?? null;
}
No More Migration Hell
Traditional databases make you write migration scripts every time you tweak the schema. In healthcare, where you're constantly learning what patients actually need, this becomes a nightmare. Our approach lets us add new data domains just by using a new prefix—no database version bumps, no complex upgrade handlers.
We've already stress-tested this with Pain Tracker's complete health model. All those anatomical locations and symptom types work exactly the same offline as online. No compromises, no "lite" versions.
The performance boost is real too. UI hydration happens instantly because we hit localStorage first. IndexedDB runs quietly in the background making sure everything stays consistent, but it never blocks the interface. When localStorage hits quota limits or gets wiped (thanks, private browsing), the fallback kicks in seamlessly.
Service Worker: The Invisible Workhorse
Think of the service worker as your app's personal assistant who never sleeps. It handles caching, offline fallbacks, background sync triggers, and request queuing while you're not even thinking about it.
Smart Caching That Actually Makes Sense
Static stuff like HTML files, the app manifest, and offline pages get cached during installation with explicit version numbers. When we ship updates, we just bump the version and old caches disappear automatically—no manual cleanup needed.
Navigation requests are trickier. We try the network first and refresh caches dynamically. If that fails, users get either our custom offline page or the main app shell so routing keeps working. Nobody gets those awful "no connection" error pages that make you feel like the internet broke.
For static assets like CSS and JavaScript bundles, we flip the strategy—cache first with background updates. This keeps the UI snappy while quietly pulling in newer versions when available.
API calls get the most love. Network-first with cached fallbacks for resilience, plus structured offline responses when both the network and cache give up.
// Clean up old caches automatically
self.addEventListener('activate', (event) => {
event.waitUntil(caches.keys().then(names => Promise.all(
names.map(name => ![STATIC_CACHE_NAME, DYNAMIC_CACHE_NAME, CACHE_NAME].includes(name)
? caches.delete(name) : undefined))));
});
// Queue failed requests for later
async function queueFailedRequest(request) {
const data = {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body: request.method !== 'GET' ? await request.clone().text() : null,
timestamp: Date.now()
};
const queue = await swGetQueue();
queue.push(data);
await swSaveQueue(queue);
}
The Offline Queue That Just Works
This is where things get really satisfying. When a request fails offline (like saving a pain entry), we serialize everything—URL, method, headers, request body—into a dedicated cache. When connectivity returns or Background Sync fires, these requests replay automatically.
The UI doesn't skip a beat because we send back custom offline responses immediately. Users see their data saved locally, and the real sync happens invisibly behind the scenes. No spinning wheels, no confusion about whether their data is safe.
We use exponential backoff during queue processing so we don't hammer the API after outages. Versioned cache names make deployments predictable, which matters a lot when you're dealing with clinical release windows where downtime isn't acceptable.
Background Sync: The Catch-Up Artist
While the service worker handles immediate offline stuff, we've got a foreground service managing more sophisticated sync with priority awareness and conflict resolution.
Smart Queuing That Gets Priorities Right
When data needs to sync, we tag it as high, medium, or low priority. Pain entries get high priority, user preferences might be medium, and analytics data gets low. Everything gets proper auth headers before going into IndexedDB, so the sync process can handle different data types appropriately.
Retry logic uses progressive delays and tracks per-item retry counts. If something fails too many times, we report it and clear it from the queue to prevent infinite loops. Nobody wants their phone's battery dying because the app got stuck trying to sync corrupted data.
We listen for online/offline events and page visibility changes to trigger opportunistic syncs. The moment conditions improve, queued data starts flowing.
// Priority matters for healthcare data
const items = request.result.sort((a, b) => {
const order = { high: 0, medium: 1, low: 2 };
return (order[a.priority] ?? 2) - (order[b.priority] ?? 2);
});
// Backoff that doesn't give up too easily
async scheduleRetry(item, result) {
const delay = result.retryAfter || this.getRetryDelay(item.retryCount || 0);
await offlineStorage.updateSyncQueueItem(item.id!, {
retryCount: (item.retryCount || 0) + 1
});
setTimeout(() => this.syncAllPendingData(), delay);
}
Keeping Everyone Informed
Custom DOM events bubble up sync progress to the UI layer. This powers status notifications and audit logs, which are often required in healthcare environments where you need to prove data integrity.
Emergency sync paths short-circuit normal queuing for critical health data. If someone's logging emergency symptoms, that goes straight to the front of the line while still maintaining offline reliability.
Force-sync and queue-inspection APIs let clinicians verify that sensitive updates actually left the device. In regulated healthcare, that level of visibility isn't optional—it's required.
PWA Manager: Making It Feel Natural
Progressive enhancement is everything here. We detect what features are available and gracefully degrade when things aren't supported, keeping the experience inclusive across different devices and browsers.
Feature Detection That Doesn't Break
Before enabling any PWA features, we check for service worker support, push notifications, background sync, storage persistence, and install prompt availability. Missing features get disabled instead of breaking the whole experience.
// Never assume browser support
this.capabilities = {
serviceWorker: 'serviceWorker' in navigator,
backgroundSync: 'serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype,
persistentStorage: 'storage' in navigator && 'persist' in navigator.storage ?
await navigator.storage.persist() : false,
installPrompt: 'BeforeInstallPromptEvent' in window,
pushNotifications: 'Notification' in window && 'PushManager' in window,
fullscreen: 'requestFullscreen' in document.documentElement,
};
// Capture install prompts for custom UI
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
this.installPromptEvent = e;
this.dispatchCustomEvent('pwa-install-available');
});
Service worker registration respects different base paths and update channels while setting up background sync and broadcasting custom events for UI components. We request persistent storage for health data and bootstrap offline storage so components can render immediately with cached state.
Trust Through Better UX
The install experience uses captured beforeinstallprompt events to create custom install UI that matches healthcare branding instead of relying on ugly browser defaults.
Real-time sync and connectivity events flow through the app to power status banners, progress badges, and trauma-informed messaging when data is queued. Performance monitoring ensures the offline stack doesn't slow down patient intake flows—because when someone's in pain, every second of loading time matters.
The Healthcare-Specific Stuff
Emergency sync gets special treatment. Critical health data calls emergencySync, attempting immediate POST and falling back to high-priority queuing when offline. Emergency contacts never wait in a normal retry window.
Pain entries get validated and sanitized before they ever hit the queue—intensity levels, anatomical locations, timestamps, symptoms—reducing bad data when offline forms cache stale state.
Conflict resolutions are stored for audit trails, keeping regulated workflows compliant while still allowing trauma-informed merge strategies when the same data gets edited from multiple devices.
What We Actually Built
Building an offline-first healthcare PWA isn't just about throwing a cache manifest together and calling it done. It requires careful orchestration between persistence layers, service workers, background sync, and user experience signals.
Our production build weighs in around 420 KB gzipped with sub-second dev server startup, while still covering thousands of lines of clinical workflows. Automated Playwright test suites validate offline flows end to end, making sure regressions get caught before they reach patients who are already dealing with enough.
Clone the repo, run the PWA in airplane mode, and watch pain entries sync the moment connectivity returns. It's oddly satisfying to see technology work exactly like it should, especially when it's helping people manage something as personal as chronic pain.
The patterns we developed are reusable across healthcare domains where reliability literally equals safety. When your app is someone's lifeline during their worst days, "good enough" isn't good enough.
Top comments (0)