A technician finishes a job in a basement with no signal, captures a signature and four photos, taps Submit and drives off. If your web app treats that tap as a normal HTTP request, the work is lost or, worse, duplicated when the technician retries in the car park. Offline-first means the device is the first place data is written, the server is the second, and a sync protocol makes sure every record arrives exactly once.
I run Loop Origin, a software company that builds custom web applications and portals, usually on Laravel with Vue or Nuxt. I wrote this with help from AI tools and checked every command and claim myself.
How do you build an offline-capable field service web app?
Direct answer: Write every form submission to IndexedDB first as an outbox entry with a client-generated UUID, then sync the outbox to the server when the device is online. The server must accept each UUID once (idempotent), store photos separately, and reject stale edits with a version check. A service worker caches the app shell so it opens without signal.
The four layers
| Layer | Job | Tooling | Failure if missing |
|---|---|---|---|
| App shell cache | Open the app with no network | Service worker + Cache API (Workbox or hand-written) | White screen or browser offline page |
| Local data store | Hold jobs, forms, photos | IndexedDB (via the idb wrapper or Dexie) | Data lost on reload |
| Outbox and sync | Send changes when online, exactly once | Client UUIDs, retry loop, Background Sync where supported | Duplicates or silent loss |
| Server contract | Accept, deduplicate, resolve conflicts | Laravel endpoint with unique key and version column | Double invoices, overwritten edits |
Browser storage: pick the right box
| Store | Capacity | Async | Holds binary | Use for |
|---|---|---|---|---|
localStorage | Small, typically around 5 MB per origin | No, blocks the main thread | Strings only | A device ID, nothing else |
| IndexedDB | Large, a share of free disk set by the browser | Yes | Yes, Blob directly | Jobs, outbox, photos |
| Cache API | Large, same quota pool | Yes | Yes | App shell, static assets, reference data responses |
Two browser rules decide whether data survives:
- Ask for persistence.
navigator.storage.persist()asks the browser not to evict your origin under storage pressure. Call it after sign-in and watchnavigator.storage.estimate(). - Plan for Safari's seven-day rule. WebKit can delete script-writable storage, including IndexedDB, for sites the user has not interacted with for seven days of browser use. Web apps added to the home screen are treated differently. For iOS field teams, installing the PWA to the home screen is part of onboarding, not optional.
The outbox pattern on the device
Every action becomes an outbox row before anything touches the network:
// outbox.ts import { openDB } from 'idb'; export const db = await openDB('field', 1, { upgrade(d) { d.createObjectStore('outbox', { keyPath: 'id' }); d.createObjectStore('photos', { keyPath: 'id' }); }, }); export async function queueJobCompletion(jobId: string, version: number, form: Record<string, unknown>, photos: Blob[]) { const id = crypto.randomUUID(); const tx = db.transaction(['outbox', 'photos'], 'readwrite'); const photoIds: string[] = []; for (const blob of photos) { const pid = crypto.randomUUID(); photoIds.push(pid); await tx.objectStore('photos').put({ id: pid, outboxId: id, blob, uploaded: false }); } await tx.objectStore('outbox').put({ id, type: 'job.complete', jobId, baseVersion: version, payload: { ...form, photoIds }, attempts: 0, createdAt: Date.now(), }); await tx.done; void flushOutbox(); } The UUID is generated once, on the device, and never changes across retries. That single decision is what makes the server side safe.
The sync loop sends photos first, then the record that references them:
export async function flushOutbox() { if (!navigator.onLine) return; for (const item of await db.getAll('outbox')) { try { for (const pid of item.payload.photoIds) { const p = await db.get('photos', pid); if (!p || p.uploaded) continue; const fd = new FormData(); fd.append('file', p.blob, `${pid}.jpg`); const r = await fetch(`/api/photos/${pid}`, { method: 'PUT', body: fd }); if (!r.ok) throw new Error(`photo ${r.status}`); await db.put('photos', { ...p, uploaded: true }); } const res = await fetch('/api/sync/job-completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': item.id }, body: JSON.stringify(item), }); if (res.ok || res.status === 409) { await db.delete('outbox', item.id); // 409 is handled in the conflict UI } else { throw new Error(`sync ${res.status}`); } } catch { await db.put('outbox', { ...item, attempts: item.attempts + 1 }); break; // stop on first failure, retry later in order } } } window.addEventListener('online', () => void flushOutbox()); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') void flushOutbox(); }); In production, keep the 409 body on the device so the technician sees the conflict.
When does sync actually run?
| Trigger | Where it works | Catch |
|---|---|---|
Background Sync API (sync event in the service worker) | Chromium browsers, including Chrome on Android | Not supported in Safari or Firefox |
online event | All browsers | Fires on any network, including captive Wi-Fi that blocks requests |
| App becomes visible | All browsers | Requires the technician to open the app |
| Manual "Sync now" button with pending count | All browsers | Requires training, but it builds trust |
I use all four. The visible pending count ("3 items waiting to upload") matters more than any API, because technicians stop double-submitting.
The server contract in Laravel
The endpoint must be safe to call twice. A unique column on the client UUID enforces that:
// migration Schema::create('job_completions', function (Blueprint $t) { $t->id(); $t->uuid('client_uuid')->unique(); $t->foreignId('job_id')->constrained(); $t->foreignId('technician_id')->constrained('users'); $t->json('payload'); $t->timestamp('captured_at'); $t->timestamps(); }); // SyncJobCompletionController.php public function __invoke(SyncJobCompletionRequest $request) { $data = $request->validated(); return DB::transaction(function () use ($data, $request) { if ($existing = JobCompletion::where('client_uuid', $data['id'])->first()) { return response()->json($existing, 200); // replay: same answer } $job = Job::lockForUpdate()->findOrFail($data['jobId']); if ($job->version !== $data['baseVersion']) { return response()->json([ 'conflict' => true, 'server' => $job->only(['status', 'version', 'updated_at']), ], 409); } $completion = JobCompletion::create([ 'client_uuid' => $data['id'], 'job_id' => $job->id, 'technician_id' => $request->user()->id, 'payload' => $data['payload'], 'captured_at' => Carbon::createFromTimestampMs($data['createdAt']), ]); $job->update(['status' => 'completed', 'version' => $job->version + 1]); return response()->json($completion, 201); }); } Three choices carry the weight. The existence check returns the original result for a replay, so a retry after a lost response is harmless. The unique index is the real guarantee if two retries race; catch the duplicate-key exception and return the existing row. And captured_at comes from the device, so reports show when work happened, not when the van found signal. The same row-locking reasoning appears in my guide to preventing double booking with locks, holds and expiry.
Photos go to PUT /api/photos/{uuid}, idempotent because the client chose the key. Validate type and size and store them outside the web root, as covered in secure SaaS file uploads. Slow follow-up work, like PDF reports, goes on a queue.
Conflict policy: decide it before you code it
| Data type | Policy | Why |
|---|---|---|
| Job completion, signatures, photos | Append-only; never overwrite | Evidence should not be lost |
| Job status | Version check; 409 to the technician on mismatch | A dispatcher may have cancelled or reassigned it |
| Notes | Merge both versions with author and time | Low risk, high annoyance if lost |
| Inventory used on site | Record as movements, not totals | Totals from two devices cannot be merged |
Build vs buy
| Option | Strengths | Weak spots | Good fit |
|---|---|---|---|
| Off-the-shelf field service SaaS | Fast start, offline mobile apps included | Per-seat pricing, fixed workflows, data export limits | Standard service jobs with few integrations |
| Native iOS/Android app | Strongest offline storage and background work | Two codebases or a cross-platform framework, app store releases | Heavy media, long offline periods, hardware integrations |
| Offline-first PWA on your backend | One codebase, instant updates, uses your existing Laravel API | Safari limits, no true background sync on iOS | Custom workflows, mixed devices, existing web portal |
Rollout checklist
- [ ] Cache the app shell and reference data with a service worker
- [ ] Store jobs, outbox and photos in IndexedDB, never
localStorage - [ ] Call
navigator.storage.persist()and show storage usage - [ ] Install to the home screen as part of iOS onboarding
- [ ] Generate one UUID per action on the device and reuse it on every retry
- [ ] Unique index on the client UUID; return the original result on replay
- [ ] Version column and 409 response for stale status edits
- [ ] Visible pending-items counter and a "Sync now" button
- [ ] Field test in airplane mode, on captive Wi-Fi and with a full device
If you are weighing this against an off-the-shelf tool, it is the kind of build we scope under custom web application development.
Frequently asked questions
Can a PWA work fully offline?
Yes, once it has loaded at least once. The service worker serves the app shell from cache, and IndexedDB holds the data. Anything that needs the server, such as fresh job assignments, waits until the device reconnects.
Does Background Sync work on iPhones?
No. Safari does not support the Background Sync API. On iOS, sync runs when the app is open or comes back into view, so show a pending counter and a manual sync button.
How do I stop duplicate submissions when the network is flaky?
Generate a UUID on the device when the action happens and send it with every retry. The server keeps a unique index on it and returns the original result for repeats.
What happens if two people edit the same job offline?
Decide per field. Evidence like photos and signatures is append-only. Status changes use a version check and return a conflict for a human to resolve. Notes can be merged.
Will the browser delete my offline data?
It can, under storage pressure or, on Safari, after seven days without interaction for sites not added to the home screen. Request persistent storage, install the app, and sync often.
Follow & Connect
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)

