Building Offline-First PWAs: Service Worker Sync & IndexedDB Guide

Nauman M.
Nauman M.CEO & Head of Engineering @ Groooh
https://res.cloudinary.com/dbonkjhet/image/upload/v1785243648/groooh/blog-images/gi9ccdoegjquhsldixwl.jpg

How to build resilient offline-first Progressive Web Apps. Configure background sync, cache fallbacks, and local database hydration using IndexedDB.

Network reliability is a myth. Whether your user is commuting on a subway, working from a crowded coffee shop, or dealing with spotty local Wi-Fi, their connection will eventually drop. If your Progressive Web App (PWA) is not built with an offline-first architecture, that network drop results in a crashed session, broken layouts, and lost customer data.

If a user is posting a message, modifying their cart, or submitting a complex dynamic form, you cannot afford to drop that payload just because their connection went silent.

The standard approach of showing an offline error screen and asking the user to try again is no longer acceptable. A production-ready PWA must continue working silently in the background, caching operations locally and syncing them back to the database the millisecond a connection returns.

The Strategy: Shifting to Local-First Operations

To make an app truly resilient, you must decouple the user interface from direct network dependencies. Instead of sending POST or PUT network requests directly to your API gateway, your application frontend must write user inputs to a local database first.

IndexedDB is the industry baseline for this layer. Because it is asynchronous and built directly into modern web browsers, it handles heavy data operations without locking up the user interface.

Once the payload is safely committed to IndexedDB, your service worker registers a synchronization tag. When the device regains Internet access, the web browser fires a background sync event, giving your service worker the system resource it needs to read the local IndexedDB queue and dispatch the requests safely to your backend.

The Mechanics of Background Sync

Historically, if a user closed their browser tab before a network request completed, that data was lost forever. The modern Background Sync API completely solves this problem. Because service workers run as independent background processes, they can execute sync tasks even if the user has navigated away from your website.

The workflow operates under a simple execution pipeline:

  1. The frontend attempts to perform an action (like submitting a form).
  2. If the network is down, the frontend catches the failure and writes the structured action parameters directly to IndexedDB.
  3. The frontend requests a sync registration from the service worker using a unique identifier tag, like:
    swRegistration.sync.register('sync-payload-queue')
  4. The user can safely close the application.
  5. The device regains cellular or Wi-Fi signal.
  6. The browser triggers the onsync event inside the active service worker.
  7. The service worker retrieves the offline payloads from IndexedDB, executes the API calls, and clears the local queue upon receiving a successful status code.

This architecture protects your application integrity and ensures your users never lose progress, making your web application feel as robust and stable as a native iOS or Android app.

Technical Implementation: Writing the Synchronization Pipeline

To build this pipeline, you need to combine IndexedDB storage inside your main application thread with a sync event listener inside your custom service worker file.

Here is how you register the sync event from your frontend application when a network request fails:

// Registering the background sync within your client page  
async function queueOfflinePayload(payload) {  
  const db = await openLocalDatabase();  
    
  // Write the request payload to IndexedDB first  
  await db.transaction('sync-store', 'readwrite')  
    .objectStore('sync-store')  
    .put({ id: Date.now().toString(), data: payload });

  // Register the sync tag with the active service worker registration  
  const registration = await navigator.serviceWorker.ready;  
  if ('sync' in registration) {  
    try {  
      await registration.sync.register('sync-payload-queue');  
      console.log('Background sync registered successfully');  
    } catch (err) {  
      console.warn('Sync registration failed, falling back to network dynamic fetch:', err);  
    }  
  }  
}

Once registered, your service worker runs in a separate background thread. Even if the user closes your app, the browser will wake up the service worker when the connection returns, triggering this background handler

// Service worker sync event handler (sw.js)  
self.addEventListener('sync', (event) => {  
  if (event.tag === 'sync-payload-queue') {  
    event.waitUntil(processOfflineQueue());  
  }  
});

async function processOfflineQueue() {  
  const db = await openLocalDatabase();  
  const tx = db.transaction('sync-store', 'readonly');  
  const store = tx.objectStore('sync-store');  
  const queuedRequests = await store.getAll();

  for (const record of queuedRequests) {  
    try {  
      const response = await fetch('https://api.yoursite.com/sync-endpoint', {  
        method: 'POST',  
        headers: { 'Content-Type': 'application/json' },  
        body: JSON.stringify(record.data)  
      });

      if (response.ok) {  
        // Clear successful records from IndexedDB  
        const deleteTx = db.transaction('sync-store', 'readwrite');  
        await deleteTx.objectStore('sync-store').delete(record.id);  
      }  
    } catch (err) {  
      console.error('Failed to dispatch background payload, will retry on next connection:', err);  
      // Throwing an error here signals the browser to schedule another retry later  
      throw err;  
    }  
  }  
}

Resolving Sync Conflicts

When you write data offline and sync it later, you will eventually face synchronization conflicts: the state on your server might have changed while the user was offline.

To prevent data corruption, your background sync process must implement robust conflict-resolution strategies:

  • Vector Clocks or Timestamps: Every offline mutation should be tagged with a local sequence number or generation timestamp.
  • Idempotent API Endpoints: Your backend must be configured to process duplicate sync payloads safely, ensuring that if a service worker retries a failed network post, it does not create duplicate database records.
  • Server-Assigned Versioning: If the server detects that the resource has been modified by another client in the meantime, the background sync worker should write the conflict state back to IndexedDB and alert the user on their next session to review and merge the changes.

By handling these edge cases systematically, your web product can achieve absolute synchronization reliability under any network condition.

Need help bulletproofing your PWA offline architecture? Groooh specializes in resilient mobile web deployments, database synchronization, and advanced service worker pipelines. Reach out to our senior engineering team to schedule a technical architecture review.

Nauman M.
Written byCore Contributor

Nauman M.

CEO & Head of Engineering @ Groooh

A seasoned systems architect with over a decade of experience guiding venture-backed startups and growth teams from 0-to-1 MVP delivery to scale. Specializes in cross-platform mobile architectures, resilient cloud infrastructure, and applied agentic AI integrations that drive measurable commercial impact.

Have a Project in Mind?

Ready to build your next breakthrough product?

Let’s collaborate on your architecture roadmap, MVP sprint, or full product build with our senior team.

Start a Project

Relevant Blogs