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:

  1. Ask for persistence. navigator.storage.persist() asks the browser not to evict your origin under storage pressure. Call it after sign-in and watch navigator.storage.estimate().
  2. 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.

A stack of blank paperwork in the bed of a pickup truck beside a field, representing field work captured away from reliable signal
AI-generated image.

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