At a Toyota event in Colombia, connectivity isn't a guaranteed luxury: it's a packed hall, saturated mobile networks, and booths where the WiFi comes and goes. That's where I built, with the Destiny team, a Next.js PWA that didn't depend on the signal to work. Not a "network-fault-tolerant" app, but one designed from day one to operate offline and reconcile once the signal came back. That difference in mindset —offline-first, not offline-tolerant— changed how we designed storage, sync, and above all how we surfaced state to non-technical people who were busy attending to customers right there on the floor.
Offline-first is not the same as offline-tolerant
An offline-tolerant app assumes the network is the norm and offline is the exception: when it fails, it shows a friendly error and waits. An offline-first app flips that premise. The immediate source of truth is local; the network is an implementation detail used to propagate and refresh data when it happens to be available.
At the Toyota event this wasn't an aesthetic choice. A rep capturing an interested customer's details can't sit staring at a spinner because the hall has saturated the mobile network. The capture had to complete every time, save locally, and go out to the server whenever it could. The backend was serverless on AWS —Amplify, Lambda, and DynamoDB— but from the frontend's point of view, the server might not exist for minutes and the app stayed fully usable.
Caching strategies in the service worker
The service worker is the heart of an offline-first PWA. The key is not to use a single strategy, but to pick one per resource type:
- App shell (precache): HTML, JS, CSS, and shell assets are precached on install. The app boots with no network.
- Stale-while-revalidate: for resources that can be slightly out of date (catalog images, icons). I serve the cache instantly and refresh in the background.
- Network-first with cache fallback: for data I want fresh when there's signal, but that can't block the experience when there isn't.
// sw.js — routed by resource type
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API data: network-first with cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request)
.then((res) => {
const copy = res.clone();
caches.open('api-cache').then((c) => c.put(request, copy));
return res;
})
.catch(() => caches.match(request))
);
return;
}
// Catalog assets: stale-while-revalidate
if (url.pathname.startsWith('/catalog/')) {
event.respondWith(
caches.match(request).then((cached) => {
const network = fetch(request).then((res) => {
caches.open('catalog-cache').then((c) => c.put(request, res.clone()));
return res;
});
return cached || network;
})
);
return;
}
// App shell: cache-first
event.respondWith(caches.match(request).then((c) => c || fetch(request)));
});
Pending writes in IndexedDB
Reading offline is easy; writing offline is where the real work lives. Every data capture a rep made was first saved to IndexedDB as a pending operation, with its own client-generated id (a UUID), a timestamp, and the full payload. The UI gave immediate feedback: the record was saved, full stop. It didn't wait on the server.
That local queue is what turns offline into something you can trust. Nothing lives only in memory; if the rep closed the tab or ran out of battery, the operation was still there on reopen.
💡 In offline-first the network isn't the source of truth, it's just a sync channel. If your UI waits on the server to confirm anything, you're still building an offline-tolerant app in disguise.
Deferred sync when the signal returns
When connectivity came back, that queue had to be drained. I used the Background Sync API to hand that work to the browser: I register a sync event and the system itself decides when there's a stable network to run it, even if the tab is no longer in the foreground.
// When queuing a write, ask for a deferred sync
async function queueWrite(record) {
await idbPut('pending-writes', record); // save to IndexedDB
const reg = await navigator.serviceWorker.ready;
if ('sync' in reg) {
await reg.sync.register('flush-writes');
}
}
// In the service worker: drain the queue when the browser allows it
self.addEventListener('sync', (event) => {
if (event.tag === 'flush-writes') {
event.waitUntil(flushPendingWrites());
}
});
async function flushPendingWrites() {
const pending = await idbGetAll('pending-writes');
for (const record of pending) {
const res = await fetch('/api/leads', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': record.id, // the client UUID
},
body: JSON.stringify(record),
});
if (res.ok) await idbDelete('pending-writes', record.id);
}
}
Conflicts and idempotency
The scenario you can't ignore: the write did reach the server, but the response got lost on the network before coming back. The client thinks it failed and retries. Without protection, you duplicate the record.
The fix was idempotency using the client's key. That UUID generated when the data was captured traveled as the Idempotency-Key. In the Lambda, if a record with that key already existed, we returned the existing result instead of creating a new one. Retrying was safe by design: a thousand retries produced a single record.
For edit conflicts on the same piece of data, the rule was simple and explainable: last-write-wins by client timestamp. It wasn't CRDTs or anything sophisticated, but for the event's flow —mostly independent captures— it was enough and predictable.
The UX of sync state
Everything above is invisible to the rep, and that's how it should be almost always. But "almost" is the operative word. The person at the booth needs confidence, not technical detail. We showed three simple states:
- Saved (local, not yet synced): a check and "saved on device."
- Syncing: a discreet indicator while the queue was draining.
- Synced: confirmation the data now lived on the server.
I also showed a "pending to send" counter. That reassured a non-technical person: they knew nothing had been lost even without signal, and they knew when everything was safe before closing out their shift.
What I'd do differently in 2026
Today I'd rethink a couple of things. First, I'd lean more on mature libraries instead of writing so much by hand: Workbox for the service worker strategies, and a layer like Dexie over IndexedDB instead of the raw API. Second, for sync I'd evaluate a real local-first engine —something with CRDT-based reconciliation when the flow justifies it— so I'm not locked into last-write-wins in cases with genuine concurrent editing. And third, I'd be more deliberate about observability: record queue metrics (size, operation age, retry rate) to diagnose in the field, because at an event you don't have time to open DevTools. The core idea, though, I wouldn't touch: the signal is optional, the work is not.