DEV Community

137Foundry
137Foundry

Posted on

Why Your Service Worker Dies Before Your Push Notification Renders

You wire up web push. You generate VAPID keys, register a service worker, subscribe a user, and send a test push from your server. The push dispatch returns 201. The browser receives it. And nothing happens. No notification appears. No error in the console. Just silence.

This is the most common web push bug, and it has the same root cause about 80% of the time: the service worker terminated before the notification rendered. The fix is one line of code. The underlying mental model is worth understanding because the same gotcha applies to every async operation in a service worker, not just push.

What's actually happening

Service workers are not always running. The browser keeps them alive only as long as they have active work, then aggressively terminates them to free memory. This is a feature, not a bug, but it has a sharp edge: if your push handler kicks off an async operation and returns without explicitly telling the browser to wait, the browser may kill the worker before the operation completes.

The broken pattern looks like this:

self.addEventListener('push', (event) => {
  const data = event.data.json();
  self.registration.showNotification(data.title, {
    body: data.body,
    icon: '/icons/notify.png',
  });
});
Enter fullscreen mode Exit fullscreen mode

It looks fine. It probably works some of the time, especially in development with DevTools attached (DevTools keeps the worker alive). In production, it fails silently. The showNotification call returns a Promise that resolves when the notification is rendered. By the time that promise resolves, the worker has been killed and the rendering operation is gone.

The fix:

self.addEventListener('push', (event) => {
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/notify.png',
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

event.waitUntil() tells the browser: "this event isn't done until this promise resolves. Don't kill me yet." The worker stays alive long enough for the notification to render.

data center cooling pipes close-up with status lights
Photo by Juan Hernandez Jr on Pexels

Why this is so easy to miss

The bug doesn't reproduce reliably during development. Three things hide it:

  1. DevTools keeps the worker alive. Open DevTools, your push handler works fine. Close DevTools, ship to prod, it stops working.
  2. The browser is aggressive about termination in production. Chrome terminates idle service workers within 30 seconds of inactivity in many cases. In development, it's often longer.
  3. No console error. The worker is killed, the notification just doesn't render, no error fires. There's nothing in your logs to point at.

You only notice when the push delivery analytics show high dispatch rates but low rendered-notification rates, which you have to instrument yourself because no platform reports it.

The pattern generalizes beyond push

event.waitUntil() is the right tool for any async work in a service worker event. Some examples:

Background sync:

self.addEventListener('sync', (event) => {
  if (event.tag === 'upload-queued-files') {
    event.waitUntil(uploadQueuedFiles());
  }
});
Enter fullscreen mode Exit fullscreen mode

Install (caching):

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then((cache) => cache.addAll(STATIC_ASSETS))
  );
});
Enter fullscreen mode Exit fullscreen mode

Activate (cleanup):

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== 'v1').map((k) => caches.delete(k)))
    )
  );
});
Enter fullscreen mode Exit fullscreen mode

The rule of thumb: any time a service worker event handler does async work, wrap it in event.waitUntil(). If you're returning a promise from a handler, you almost certainly want it inside a waitUntil.

The companion bug: notificationclick that doesn't bring users back

The other place service workers silently die is in the notificationclick handler. The naive pattern:

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  clients.openWindow(event.notification.data?.url);
});
Enter fullscreen mode Exit fullscreen mode

clients.openWindow() returns a Promise. If the worker terminates before the window opens, the click does nothing. The fix is identical:

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data?.url ?? '/')
  );
});
Enter fullscreen mode Exit fullscreen mode

A more sophisticated version focuses an existing tab if the user already has the app open, rather than opening a new one:

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const targetUrl = event.notification.data?.url ?? '/';
  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true })
      .then((windowClients) => {
        const existing = windowClients.find((c) => c.url === targetUrl);
        if (existing) {
          return existing.focus();
        }
        return clients.openWindow(targetUrl);
      })
  );
});
Enter fullscreen mode Exit fullscreen mode

This is also the pattern that prevents the "I clicked the notification and now I have eight tabs open" complaint.

How to verify your handler is doing the right thing

Three checks before shipping:

  1. Read the MDN service worker reference for ExtendableEvent.waitUntil. Confirm you understand which events extend ExtendableEvent (push, install, activate, sync, notificationclick all do).
  2. Use the Chrome DevTools Application panel to simulate a push. Trigger it, then immediately verify the notification appeared. If it didn't, the handler is broken.
  3. Disable "Update on reload" in DevTools and close DevTools. Refresh the page, wait 60 seconds, dispatch a real push from your server. This is the closest simulation of production conditions where the worker has been idle.

In the dev journal that the team at 137Foundry maintains, the most common version of this bug shows up not in initial implementation but in refactors. Someone changes the push handler to "make it cleaner," removes the waitUntil, ships it, and the notification rendering rate drops 20%. The dispatch logs still look fine. Nobody catches it for weeks until someone notices CTR is down.

A test that catches this in CI

You can't easily run a full integration test for push in CI, but you can lint for the pattern. A custom ESLint rule that flags showNotification calls not wrapped in event.waitUntil catches the bug at code review time:

// .eslintrc custom rule (pseudocode)
{
  'service-worker/wait-until-on-show-notification': 'error'
}
Enter fullscreen mode Exit fullscreen mode

We've shipped this rule on several client projects. It catches the regression every few months, which is exactly the cadence at which the bug would otherwise sneak back in via refactors.

Why this happens so often

Service workers feel like normal JavaScript. They have event listeners, promises, async/await. They look like the rest of your frontend code. They are not. The lifecycle is different. The execution context is different. The termination behavior is different.

Anyone learning service workers for the first time will run into this. The fix is small, but the mental model that produces the fix (workers are aggressively terminated; you must explicitly extend their lifetime for async work) requires understanding that doesn't transfer from other JavaScript contexts.

For a full integration walkthrough that gets all the lifecycle pieces right, our broader article on how to add web push notifications to a PWA covers VAPID setup, subscription storage, dispatch patterns, and the operational discipline (frequency caps, quiet hours, lifecycle handling) that determines whether push remains a healthy channel a year later.

You can find more deep-dives on service worker patterns and PWA architecture at 137foundry.com. The MDN service worker API docs are the authoritative reference; we recommend reading them end-to-end once before shipping serious service worker code, then revisiting the specific event handlers as you implement each. The web.dev PWA documentation is the most readable production-readiness companion to the MDN spec.

The takeaway

event.waitUntil() is not a stylistic choice. It is the only thing keeping your service worker alive long enough to do the work the event triggered. Skip it and your worker dies; your push notifications never render; your push integration looks broken even though every other layer (dispatch, subscription, encryption) is working perfectly.

Add it to every async operation in every service worker event handler. Lint for it in CI. Verify it in DevTools before shipping. The one-line fix is the difference between a working push integration and a silent failure that takes weeks to diagnose. Get it right once and you stop debugging the same bug in three months.

Top comments (0)