Skip to content

Web Architecture

Service Workers — Offline-First for SaaS Field Apps

Workbox + IndexedDB + Background Sync = Your Dashboard Works Offline.

🔌 📡 ✍️

The Field Reality

Your Velocity X SaaS app is brilliant on a fibre connection. But your tradie is on a job site in rural QLD with 1 bar of signal. Your driver's phone drops cellular for 90 seconds at a border crossing. Your OSH inspector is deep in a warehouse when the network goes sideways.

In those moments, most web apps freeze. They refresh. They lose unsaved work. Users rage-quit.

Service workers turn your web app into a resilient field tool. They cache your app so it loads instantly—no network required. They queue writes to IndexedDB when the user is offline. When connectivity returns, they sync changes back to the server automatically. Your SaaS becomes a hybrid: web-scale backend, offline-first frontend.

This isn't theoretical. Velocity X deployments for field teams use this pattern in production. Tradies save job notes, photos, quotes—offline. Everything syncs when they reconnect. Zero lost work. Zero frustration.

What Is a Service Worker?

A service worker is a JavaScript background process that runs in your browser, separate from your main app. It sits between your app and the network. Every HTTP request passes through it.

Think of it like a sentry at the gate:

  • Request comes in → service worker intercepts → "Do we have this cached? Great, return it instantly. If not, request from network and cache for later."
  • Your app is offline → requests fail → service worker returns the last-known-good cached response instead.

Service workers persist across browser tabs and survive page refreshes. They can handle background sync, push notifications, periodic cache updates. They're the foundation of PWAs (Progressive Web Apps).

Offline-First Matters for Field Apps

Field teams lose connectivity constantly. They're not choosing to go offline—the job site doesn't have coverage. They're moving from site to site. They lose signal in tunnels, basements, remote areas.

If your app requires a network connection to function, you've already lost. The user either quits or pulls out pen and paper. You've rebuilt the problem you were hired to solve.

Offline-first flips this: connectivity is optional. The app works fully. When you reconnect, changes sync silently. You never think about it.

For Velocity X (dashboards used by tradies + drivers + OSH assessors), this means:

  • Job notes saved offline → sync when network returns
  • Photos uploaded without cellular → queue and send when home
  • Quotes edited with zero bars → changes persist locally → sync when reconnected
  • No "your connection was lost" modals. No lost work. No rage.

Real example: a hail assessor writes 50 damage reports on a Friday. Coverage is spotty. She saves all 50 locally. By evening, she's back at the office, opens the app, and all 50 sync to the server in 3 seconds. Zero manual upload.

Workbox Setup (Precaching)

Workbox is a library from Google that automates service worker boilerplate. Instead of writing fetch handlers by hand, you tell Workbox "cache these files" and it does the rest.

For an Astro site (like Velocity 9), you install the integration:

npm install -D @astrojs/service-worker

Then add it to astro.config.mjs:

export default defineConfig({
  integrations: [
    serviceWorker(),
    // ...other integrations
  ],
});

This generates a service worker automatically. It precaches your static assets (CSS, JS, fonts, images). When the app loads, it installs instantly from cache. No spinners, no waiting.

For custom logic, create src/service-worker.ts:

import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';

// Precache everything the build generated
precacheAndRoute(self.__WB_MANIFEST);

// Images: cache first, fallback to network (old images are fine)
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({ cacheName: 'images-v1' })
);

// API calls: network first, fallback to cache (try live first)
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({ cacheName: 'api-v1' })
);

Now your app:

  • Loads 90% faster on repeat visits (everything cached)
  • Works offline for static pages
  • Attempts live API calls first, falls back to cached responses

But this isn't enough for a field app. You can't cache a "create job" API call. The user needs to write data offline and sync it when reconnected.

IndexedDB Write Queue

IndexedDB is a client-side database. It's like localStorage, but faster, bigger (50MB+), and designed for structured data.

The pattern: when the user creates a job offline, save it to IndexedDB instead of trying to POST it to the server. When connectivity returns, grab all queued writes from IndexedDB and flush them to the backend.

Real implementation:

// src/lib/offline-queue.ts
export async function queueWrite(operation: {
  action: 'create' | 'update' | 'delete';
  resource: 'jobs' | 'photos' | 'quotes';
  data: any;
  timestamp: number;
}) {
  const db = await openDB('velocity-app', 1, {
    upgrade(db) {
      db.createObjectStore('pending-writes', { keyPath: 'id', autoIncrement: true });
    },
  });

  await db.add('pending-writes', operation);
  console.log('Queued offline:', operation.action, operation.resource);
}

export async function getPendingWrites() {
  const db = await openDB('velocity-app', 1);
  return db.getAll('pending-writes');
}

export async function clearPendingWrites(ids: number[]) {
  const db = await openDB('velocity-app', 1);
  const tx = db.transaction('pending-writes', 'readwrite');
  ids.forEach(id => tx.store.delete(id));
  await tx.done;
}

In your React component:

// src/components/CreateJob.tsx
const handleCreateJob = async (formData: JobData) => {
  try {
    // Try to POST to server
    const response = await fetch('/api/jobs', {
      method: 'POST',
      body: JSON.stringify(formData),
    });
    if (response.ok) {
      console.log('Job created online');
      return;
    }
  } catch (error) {
    // Network failed, save offline
    console.log('No connection, queuing offline');
  }

  // Queue the write
  await queueWrite({
    action: 'create',
    resource: 'jobs',
    data: formData,
    timestamp: Date.now(),
  });

  showToast('Saved offline. Will sync when you reconnect.');
};

User creates a job. Network fails. The write goes to IndexedDB. App shows "Saved offline". User keeps working. No lost data.

Background Sync

Background Sync is a browser API that says: "When connectivity returns, run this function."

The pattern: when the user goes online, trigger a sync event. Listen for that event in your service worker and flush the queue:

// src/service-worker.ts
self.addEventListener('sync', event => {
  if (event.tag === 'sync-pending-writes') {
    event.waitUntil(syncPendingWrites());
  }
});

async function syncPendingWrites() {
  const writes = await getPendingWrites();

  for (const write of writes) {
    try {
      const response = await fetch(<code>/api/${write.resource}</code>, {
        method: write.action === 'create' ? 'POST' : 'PUT',
        body: JSON.stringify(write.data),
      });

      if (response.ok) {
        await clearPendingWrites([write.id]);
        console.log('Synced:', write.resource);
      }
    } catch (error) {
      console.error('Sync failed, will retry:', error);
      // Don't clear the queue—browser will retry automatically
    }
  }
}

And in your app, request a sync when you detect connectivity:

// src/lib/sync-manager.ts
export function requestBackgroundSync() {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    navigator.serviceWorker.ready.then(sw => {
      (sw as any).sync.register('sync-pending-writes');
    });
  }
}

// Call this when online
window.addEventListener('online', requestBackgroundSync);

Now the flow is seamless:

  1. User goes offline, creates job
  2. Job saved to IndexedDB
  3. User reconnects
  4. Browser triggers background sync
  5. Service worker flushes the queue
  6. All jobs POST to server silently
  7. UI updates with server responses

Six FAQs

Q: What if the user closes the app before syncing? A: The queue lives in IndexedDB, which persists across sessions. Next time they open the app, you call getPendingWrites() on startup and sync anything that wasn't flushed.

Q: Do I need a service worker for every route? A: No. The service worker is installed once and handles all routes globally. But you only use offline queueing for routes/APIs that need it (e.g., create job, save quote, upload photo). List reads (get all jobs) can fail gracefully offline.

Q: What about conflicts if the user edits the same job on two devices? A: This is a real problem called last-write-wins. Use server-side timestamps and conflict resolution (e.g., "server version wins if newer than local"). For field apps, conflicts are rare (one person per device), but design for it anyway.

Q: Can service workers access user auth tokens? A: Yes. Store the token in a secure httpOnly cookie, or in sessionStorage, and the service worker can read it for background sync requests. Be careful—intercepting all requests means you see sensitive data.

Q: What browsers support this? A: Service workers: 95%+. IndexedDB: 98%+. Background Sync: ~70% (missing Safari on iOS). Graceful degradation is key: service workers improve the experience, but the app still works on older browsers without them.

Q: How do I test offline mode locally? A: DevTools → Network tab → offline checkbox. This simulates zero connection. Also test "slow 3G" to see how your queueing handles poor connectivity (where requests timeout mid-flight).

The Verdict

Field apps live and die by their offline-first design. Your tradie doesn't care if your backend is a million-dollar microservices architecture. They care that they can save a job note without signal.

Service workers + IndexedDB + background sync give you that for almost free. A weekend of implementation buys you reliability that costs competitors weeks.

For Velocity X, this is table stakes. Every deployment to field teams gets:

  • Workbox precaching (app loads in 300ms, cached)
  • IndexedDB queue for writes (create, update, delete offline)
  • Background sync (flushes when connectivity returns)
  • Graceful degradation (app still works on browsers without SW support)

One service worker. One queue. Infinite reconnects. Check out our pricing page to see how we build Velocity X stacks with offline-first resilience. For more on app architecture, read our PWA vs Capacitor vs React Native post—same philosophy: start simple, add features only when you need them.

Your field team will feel the difference. Zero lost work. Zero rage. That's offline-first done right.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.