Technology

Building PWAs with Offline Sync Capabilities

Service workers, background sync, local storage: architecture for apps that work offline and sync when connectivity returns.

All articles
TechnologyNexaEx TeamMay 1, 2026 9 min read
Building PWAs with Offline Sync Capabilities

Offline-First PWAs with Sync

PWAs that work offline provide the best user experience, especially on unreliable networks. This is achieved through service workers and intelligent sync strategies.

Service Workers Fundamentals

A service worker is a JavaScript worker that intercepts network requests and caches responses.

// service-worker.js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles.css',
        '/app.js',
      ])
    })
  )
})

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request)
    })
  )
})

Flow:

  1. Browser makes request.
  2. Service worker intercepts it.
  3. Check cache first. If hit, return cached response.
  4. If miss, fetch from network and cache result.
  5. Return response to page.

Caching Strategies

Cache first, network fallback:

// Strategy: Offline pages from cache, fall back to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request)
    })
  )
})

Network first, cache fallback:

// Strategy: Try network, fall back to cache if offline
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        if (!response.ok) throw new Error('Network failed')
        return caches.open('v1').then((cache) => {
          cache.put(event.request, response.clone())
          return response
        })
      })
      .catch(() => caches.match(event.request))
  )
})

Stale-while-revalidate:

// Strategy: Serve cache immediately, fetch fresh in background
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      const fetchPromise = fetch(event.request).then((response) => {
        if (response.ok) {
          caches.open('v1').then((cache) => cache.put(event.request, response.clone()))
        }
        return response
      })
      return cachedResponse || fetchPromise
    })
  )
})

Choose strategy based on content type:

  • HTML (pages): Network first. Users want fresh content.
  • CSS/JS (assets): Cache first. These rarely change.
  • Images: Stale-while-revalidate. Load fast, refresh in background.
  • API calls: Network first for real-time data, cache fallback when offline.

Background Sync

Send data to server when connection returns:

// Register sync
navigator.serviceWorker.ready.then((registration) => {
  registration.sync.register('sync-data')
})

// In service worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-data') {
    event.waitUntil(
      // Fetch pending data from IndexedDB and send to server
      getOfflineQueue()
        .then((queue) => sendToServer(queue))
        .then(() => clearOfflineQueue())
        .catch(() => {
          // Retry later if network fails
          throw new Error('Sync failed, will retry')
        })
    )
  }
})

Browser automatically retries sync when connection is restored. Timeout and retry logic are handled by the browser.

Local Storage with IndexedDB

Service workers can't access localStorage (synchronous). Use IndexedDB for offline data:

// Store offline data
function saveOfflineData(data) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('app-db', 1)

    request.onsuccess = () => {
      const db = request.result
      const tx = db.transaction('offline', 'readwrite')
      const store = tx.objectStore('offline')
      store.add(data)

      tx.oncomplete = resolve
      tx.onerror = reject
    }
  })
}

// Retrieve offline data
function getOfflineQueue() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('app-db', 1)

    request.onsuccess = () => {
      const db = request.result
      const tx = db.transaction('offline', 'readonly')
      const store = tx.objectStore('offline')
      const all = store.getAll()

      all.onsuccess = () => resolve(all.result)
    }
  })
}

Offline Data Submission

Flow for form submission when offline:

  1. User submits form.
form.addEventListener('submit', async (e) => {
  e.preventDefault()
  const formData = new FormData(form)
  const data = Object.fromEntries(formData)

  try {
    // Try to submit
    const response = await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(data),
    })
    if (response.ok) {
      showSuccess('Submitted!')
    }
  } catch (e) {
    // Offline: store for later sync
    await saveOfflineData(data)
    showMessage('Offline. Will submit when connection returns.')
  }
})
  1. Service worker syncs when online.
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-data') {
    event.waitUntil(getOfflineQueue().then(sendAll))
  }
})

async function sendAll(queue) {
  for (const item of queue) {
    const response = await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(item),
    })
    if (response.ok) {
      removeFromQueue(item.id)
    }
  }
}

UI Indicators

Show users when data is syncing:

const [syncing, setSyncing] = useState(false)

// Detect sync status
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
  navigator.serviceWorker.addEventListener('message', (event) => {
    if (event.data.type === 'SYNC_START') setSyncing(true)
    if (event.data.type === 'SYNC_END') setSyncing(false)
  })
}

return (
  <div>
    {syncing && <p>Syncing data...</p>}
    <Content />
  </div>
)

Debugging

Chrome DevTools:

  1. Open DevTools → Application tab.
  2. Service Workers section shows registered workers.
  3. Cache Storage shows cached assets.
  4. Storage > IndexedDB shows stored data.

Test offline:

  1. DevTools → Network tab.
  2. Offline checkbox → enables offline mode.
  3. App should continue functioning.

Real-World Example

A delivery app:

  1. Driver goes offline while taking orders.
  2. Orders stored in IndexedDB.
  3. Driver continues taking orders seamlessly.
  4. Driver regains connectivity.
  5. Background sync triggers, uploads all orders.
  6. Driver sees "Synced" notification.

Result: Seamless experience, no data loss, offline-first by design.

Frequently asked questions

What's the difference between cache-first and network-first?

Cache-first serves instantly from cache; network updates in background. Network-first tries network first for fresh data. Use based on content freshness needs.

Can service workers send data to server when the browser closes?

Not instantly, but background sync queues requests. When the browser reopens and connectivity returns, sync completes automatically.

How much data can IndexedDB store?

Typically 50-100MB depending on browser and device. Always check available quota before storing large amounts.

Let's build your next idea

One conversation to scope the work, meet the team, and get a proposal — usually within two business days.