EmpireUI
Get Pro
← Blog9 min read#service worker#pwa#next.js

Service Workers in Next.js: Offline Support, Background Sync

Add offline support and background sync to your Next.js app with service workers. Real code, real gotchas, and zero magic wrappers required.

abstract network nodes glowing on dark background digital connectivity

Why You'd Even Want a Service Worker in Next.js

Offline support sounds like a checkbox feature until your users start complaining about white screens on the subway. Service workers fix that. They sit between your app and the network — intercepting requests, serving cached responses, and queuing up work for when the connection comes back. That's the whole pitch.

Next.js doesn't ship a service worker by default. It's a deliberate choice — service workers are notoriously tricky to get right, especially with dynamic routes and API calls. But that doesn't mean you're stuck without them. You just have to wire things up yourself, or use something like next-pwa as a thin layer on top of Workbox.

In practice, I'd recommend writing the service worker manually for anything non-trivial. The auto-generated caching strategies from next-pwa work fine for static assets, but the moment you have authenticated API routes or per-user cached data, you need control over what actually goes into the cache. Quick aside: the library was largely unmaintained between 2022 and 2024, so check the repo activity before committing.

Worth noting: service workers only run over HTTPS (or localhost). If you're testing on a staging URL over HTTP, nothing will register and you'll waste an afternoon wondering why your navigator.serviceWorker is undefined.

Registering a Service Worker the Right Way

Start by creating public/sw.js. The public directory in Next.js maps directly to the root, so /sw.js will be accessible at the right scope. Don't put it in src — it won't get served from the root and the browser will refuse to register it due to scope restrictions.

Then register it inside a useEffect in your root layout (Next.js 13+) or _app.tsx (Pages Router). You want client-only execution here:

// app/layout.tsx or _app.tsx
'use client';
import { useEffect } from 'react';

export function ServiceWorkerRegistrar() {
  useEffect(() => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker
        .register('/sw.js')
        .then((reg) => console.log('SW registered:', reg.scope))
        .catch((err) => console.error('SW registration failed:', err));
    }
  }, []);

  return null;
}

One thing that trips people up: the scope defaults to the directory the worker file lives in. Since it's at /sw.js, the scope is / and it'll control the whole origin. That's what you want for a full PWA.

That said, if you're adding service worker support to a sub-section of a larger app — say, /dashboard only — you'd place the worker at /dashboard/sw.js and pass { scope: '/dashboard/' } in the register call. Slightly annoying but useful when you don't own the whole domain.

Caching Strategies: Pick the Right One for Each Route

There's no universal caching strategy. A shell page, an API endpoint, and a product image all have wildly different staleness tolerances. Get this wrong and you'll serve 6-month-old data from cache while your users miss real-time updates.

Here are the three strategies you'll actually use in a Next.js app:

// public/sw.js

const STATIC_CACHE = 'static-v3';
const API_CACHE = 'api-v1';

// Cache-first: great for fonts, images, icons
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Static assets — serve from cache, fall back to network
  if (/\.(png|jpg|woff2|ico|svg)$/.test(url.pathname)) {
    event.respondWith(
      caches.match(event.request).then(
        (cached) => cached ?? fetch(event.request).then((res) => {
          const clone = res.clone();
          caches.open(STATIC_CACHE).then((c) => c.put(event.request, clone));
          return res;
        })
      )
    );
    return;
  }

  // Network-first: use for API calls you want fresh
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((res) => {
          const clone = res.clone();
          caches.open(API_CACHE).then((c) => c.put(event.request, clone));
          return res;
        })
        .catch(() => caches.match(event.request))
    );
    return;
  }
});

Honestly, the stale-while-revalidate pattern is the most underused one. You return the cached version instantly (fast), then fetch fresh data in the background and update the cache for next time. It's perfect for blog listing pages, dashboards, or anything where 30-second-old data is fine.

For your actual HTML pages — the Next.js SSR output — use network-first with a cache fallback. That way users always get the freshest server-rendered HTML when online, but a previous version when offline rather than a blank screen.

Offline Fallback Pages

A service worker without an offline fallback is half-baked. When the user is offline and hits a page that isn't cached, you need something to show them — not a browser error.

Pre-cache your offline page during the service worker install event:

// public/sw.js

const OFFLINE_URL = '/offline';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) =>
      cache.addAll([
        OFFLINE_URL,
        '/',                // pre-cache the home page shell
        '/favicon.ico',
      ])
    )
  );
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

Create app/offline/page.tsx (or pages/offline.tsx in Pages Router). Keep it minimal — no API calls, no dynamic data. Just a static message, maybe your logo, and a "retry" button that calls location.reload(). You can style it however you want; pair it with something from the Empire UI component library for a polished look without reinventing the spinner.

Then in your fetch handler, catch navigation requests specifically when they fail and return the pre-cached offline page:

// Inside the fetch handler in public/sw.js
if (event.request.mode === 'navigate') {
  event.respondWith(
    fetch(event.request).catch(() =>
      caches.match(OFFLINE_URL)
    )
  );
}

One more thing — test this properly. Open DevTools, go to Application > Service Workers, and check "Offline". Hit a page you've never visited. If you see your offline UI instead of Chrome's dinosaur, you did it right.

Background Sync: Queue Requests When Offline

Background sync is what makes a PWA feel native. Your user fills out a form, loses signal, and submits. Instead of an error, the app queues that request. When connectivity returns — even if the tab is closed — the browser fires the sync and sends the data. That's powerful.

It requires the Background Sync API, which has broad support as of 2025 in Chromium-based browsers. Safari added partial support in 2024 but doesn't support periodic background sync yet. Keep that in mind.

// In your React component
async function submitOfflineReady(data) {
  try {
    const res = await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(data),
      headers: { 'Content-Type': 'application/json' },
    });
    if (!res.ok) throw new Error('Server error');
  } catch {
    // Store in IndexedDB, register sync tag
    await saveToQueue('submit-form', data);
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('submit-form');
  }
}

In the service worker, listen for the sync event and replay the queued requests:

// public/sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'submit-form') {
    event.waitUntil(replayQueue('submit-form'));
  }
});

async function replayQueue(tag) {
  const items = await getQueuedItems(tag); // your IndexedDB helper
  await Promise.all(
    items.map((item) =>
      fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(item),
        headers: { 'Content-Type': 'application/json' },
      })
    )
  );
}

Look, the hard part here isn't the sync listener — it's the IndexedDB queue. Use idb (the npm package) or write a thin wrapper around the raw API. Either way, you need a reliable place to persist pending requests that survives page reloads.

Versioning Your Service Worker and Avoiding Stale Cache Nightmares

Here's a scenario you'll hit within two weeks of shipping: you deploy a bug fix, but users keep seeing the broken version because their service worker is still serving cached HTML from three days ago. Welcome to the cache invalidation hall of shame.

The fix is cache versioning. Every time you change the service worker, bump the cache name — e.g. static-v3 to static-v4. During the activate event, delete all old caches:

// public/sw.js
const CURRENT_CACHES = ['static-v4', 'api-v1'];

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((name) => !CURRENT_CACHES.includes(name))
          .map((name) => caches.delete(name))
      )
    ).then(() => clients.claim())
  );
});

Worth noting: browsers only install a new service worker when the file content changes by at least 1 byte. So even bumping a comment line is enough to trigger the update flow. That said, you still need skipWaiting() in the install handler if you want updates to activate immediately instead of waiting for all tabs to close.

In Next.js you can automate the cache version bump with a build-time env variable. Add NEXT_PUBLIC_SW_VERSION=$(date +%s) to your build command and reference it inside your service worker file via a template step — or just use next-pwa's built-in revision injection if you're going that route.

One pattern that scales well: keep a CACHE_MANIFEST object that maps asset paths to content hashes. At install time you only cache assets whose hashes aren't already stored. This is basically what Workbox's precache module does under the hood, and it works beautifully with the Next.js output: 'export' build for fully static sites. You can browse the gradient generator or box shadow generator on Empire UI while offline once you set this up right — small win, real UX improvement.

TypeScript Types and Debugging Tools You'll Actually Use

Service worker code lives in a separate JS execution context — no DOM, no window, different globals. TypeScript's lib config needs updating or your editor will throw errors on self.addEventListener and caches.

Add this to your tsconfig.json (or a separate public/tsconfig.sw.json if you want to keep it isolated):

{
  "compilerOptions": {
    "lib": ["ESNext", "WebWorker"],
    "target": "ES2020",
    "module": "ESNext",
    "strict": true
  }
}

For debugging, Chrome DevTools is your best friend. Application > Service Workers shows registration status, update state, and lets you push sync events manually. Application > Cache Storage lets you inspect exactly what's in each named cache and delete entries without clearing all site data.

One debugging trick that saves real time: log the service worker lifecycle events to the browser console via console.log inside install and activate. Since the worker runs in a separate thread, these logs show up under the "Service Workers" source in DevTools rather than the page context. Took me a confusing 40 minutes to figure that out the first time.

If you're building UI components that should work offline — like loading skeletons from Empire UI or glass cards from the glassmorphism components — make sure their static assets (fonts, CSS, SVGs) are in your pre-cache list. Nothing breaks the illusion of offline support faster than a skeleton loader that can't load its own stylesheet. Also worth checking out nextjs-caching-strategies for how the Next.js data cache layer interacts with your service worker cache — they're separate systems and they can conflict in subtle ways.

FAQ

Does next-pwa still work with Next.js 14 and 15?

The original next-pwa by shadowwalker is mostly unmaintained. Use the community fork @ducanh2912/next-pwa instead — it's actively maintained and supports the App Router. Still, for complex use cases just write the service worker manually.

Can I use a service worker with Next.js App Router?

Yes. Place your sw.js in the public folder and register it in a Client Component inside your root layout. The worker itself doesn't care about RSC or the App Router — it intercepts network requests regardless.

Why is my service worker not updating after I deploy?

The browser won't install a new worker until the old one's controlled tabs are closed — unless you call self.skipWaiting() in the install handler. Also make sure your CDN or server isn't caching sw.js itself; serve it with Cache-Control: no-cache.

Is Background Sync supported on iOS Safari?

Partially. As of 2025, Safari supports the basic Background Sync API but not periodic background sync. Always feature-detect with 'sync' in ServiceWorkerRegistration.prototype before calling reg.sync.register().

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Workbox Service Worker in React: Caching Strategies for PWAPage Transitions in Next.js App Router: View Transitions APIProgressive Web Apps with React and Next.js in 2026Next.js Caching Deep Dive: Request, Data, Full Route and Client Cache