notificationclick Not Opening the Right Window

A user taps your notification and gets a second copy of a page they already had open — or nothing happens at all. Both symptoms come from the same handler, and almost always from the same three lines of it.

Quick answer

clients.openWindow() always creates a new window. If you call it unconditionally, every click spawns a duplicate. The fix is to enumerate existing windows with clients.matchAll({ type: 'window', includeUncontrolled: true }), focus a match if you find one, and only fall through to openWindow() if you do not. If nothing happens at all, the cause is nearly always that the navigation promise was never passed to event.waitUntil(), so the worker was torn down before the browser acted on it.

Symptom Root cause Fix
Duplicate tab on every click openWindow() called without checking matchAll() first Enumerate, match, focus()
Existing tab invisible to matchAll() includeUncontrolled left at its false default Pass includeUncontrolled: true
Nothing happens at all Navigation promise not inside event.waitUntil() Return the promise chain to waitUntil()
Focuses the tab but shows the old page focus() without navigate() Call client.navigate(url) after focusing
Match never succeeds Comparing full URL strings Compare normalised pathname, not client.url

The decision tree the handler must implement

self.clients is the service worker’s view of the browsing contexts it can reach. matchAll() returns a snapshot of them; WindowClient.focus() brings one to the foreground; WindowClient.navigate(url) changes its location in place; and clients.openWindow(url) creates a new one. Correct behaviour is a single traversal that ends in exactly one of focus() or openWindow() — never both, and never neither.

The order matters. Enumerate first, decide second, act last. A handler that calls openWindow() optimistically and then tries to close the duplicate is racing the browser and will lose on cold starts, which is precisely the case where users notice.

The focus versus openWindow decision tree notificationclick fires, the handler enumerates window clients with includeUncontrolled true, then branches: no matching client leads to clients.openWindow, a matching client leads to focus followed by navigate when the path differs. Exactly one leaf must run per click notificationclick fires clients.matchAll( type: 'window', includeUncontrolled: true ) Does a client match the target path? no match match clients.openWindow(url) creates one new window URL must be same-origin client.focus() then client.navigate(url) only if the path differs never both, never neither Every arrow in this tree lives inside the promise you hand to event.waitUntil().
Enumerate, decide, act — one traversal, one terminal call, all of it inside a single waitUntil() chain.

Scope and includeUncontrolled decide what you can even see

matchAll() defaults includeUncontrolled to false, which restricts the result to clients this worker controls. A page that was already open when the worker activated is not controlled by it until it navigates or the worker calls clients.claim(). That is the everyday case: the user has your app open in a tab, receives a push, clicks it, and gets a duplicate — because the tab they were looking at was invisible to the default matchAll() call.

Scope is the harder boundary. A worker registered at /app/sw.js with scope /app/ can never see a window at /dashboard, no matter what flags you pass, because that document is outside its scope. If your notifications need to reach the whole origin, the worker script must be served from the origin root — the same constraint that breaks subscription in the first place, covered in service worker scope errors breaking push subscribe.

What matchAll can see, with and without includeUncontrolled Three windows are open: a controlled dashboard tab, an uncontrolled reports tab opened before activation, and an admin tab outside the worker scope. The default matchAll returns only the dashboard; with includeUncontrolled true it returns the dashboard and reports; the out-of-scope admin tab is never returned. One flag decides whether the tab in front of the user exists Windows open on this origin /dashboard — controlled loaded after the worker activated /reports — uncontrolled opened before activation /admin — outside scope /app/ unreachable by any flag matchAll( type: 'window' ) returns: /dashboard only the tab the user is staring at is missed, so openWindow duplicates it matchAll( includeUncontrolled: true ) returns: /dashboard and /reports /admin still absent — scope, not a flag, is what excludes it clients.claim() during activate promotes existing tabs to controlled, but the flag is still the safe default. Serve sw.js from the origin root if notifications must reach every route.
The default matchAll() is a subset of your open tabs, not all of them — and scope is a hard ceiling nothing overrides.

Why openWindow() outside waitUntil() is dropped

clients.openWindow() is gated on the transient user activation the notification click grants, and that activation is tied to the lifetime of the event. When your handler returns, the browser considers the event dispatch complete: the worker becomes eligible for termination and the activation expires. A call issued after an await that resolved past that point either rejects with InvalidAccessError or resolves to null with no window created.

This is why the bug is intermittent. On a warm worker with a fast network, the await resolves in a few milliseconds and the window opens. On a cold start over mobile data, it does not, and the click appears to do nothing. The same trap catches client.navigate() and any fetch() you fire for analytics — a point that also matters for attributing conversions to push notification clicks.

// BROKEN — the analytics await lets the activation expire, and nothing
// keeps the worker alive, so the openWindow call is silently dropped.
self.addEventListener('notificationclick', async (event) => {
  event.notification.close();
  await fetch('/api/click', { method: 'POST' });   // handler already returned
  self.clients.openWindow(event.notification.data.url);
});

Two things are wrong there. The listener is declared async, so it returns a promise the browser ignores, and no event.waitUntil() is called at all. Keep the listener synchronous, do the async work in a named function, and pass that function’s promise to waitUntil().

URL matching pitfalls

Comparing client.url === targetUrl fails in ordinary situations. client.url is always an absolute, fully-resolved URL, while your payload almost certainly carries a relative path. A hash fragment, a tracking query parameter, or a trailing slash all break string equality while describing the same page.

Normalise both sides through the URL constructor and compare pathname, treating a trailing slash as insignificant. Compare origin explicitly too: openWindow() refuses cross-origin URLs from a service worker click handler, so a payload pointing at a marketing domain silently does nothing.

URL matching pitfalls between client.url and the target Five rows compare an absolute client URL with a relative target. Trailing slash, hash fragment and query string all break string equality but should focus the existing tab. A different origin or a different path should open a new window. Why client.url === target almost never matches client.url data.url string === correct behaviour https://ex.com/inbox /inbox/ false same page — focus it https://ex.com/inbox#msg-3 /inbox false same page — focus it https://ex.com/inbox?utm=may /inbox false same page — focus it https://cdn.ex.com/inbox /inbox false other origin — no match https://ex.com/app/inbox /inbox false other page — openWindow Rule: resolve both sides with new URL(x, self.location.origin), compare origin, then compare pathname with any trailing slash stripped. Ignore hash and query entirely.
Three of these five pairs are the same page. Only a normalised pathname comparison gets all five right.

The handler that gets it right

function samePage(clientUrl, targetUrl) {
  const a = new URL(clientUrl);
  const b = new URL(targetUrl, self.location.origin);
  if (a.origin !== b.origin) return false;
  const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, '') : p);
  return strip(a.pathname) === strip(b.pathname);
}

self.addEventListener('notificationclick', (event) => {
  const data = event.notification.data || {};
  const targetUrl = new URL(data.url || '/', self.location.origin).href;
  event.notification.close();

  const route = async () => {
    const windows = await self.clients.matchAll({
      type: 'window',
      includeUncontrolled: true
    });

    // 1. Exact page already open → focus it, do not navigate.
    for (const client of windows) {
      if (samePage(client.url, targetUrl) && 'focus' in client) {
        return client.focus();
      }
    }
    // 2. Some window on this origin → reuse it rather than spawning a tab.
    for (const client of windows) {
      if (new URL(client.url).origin === self.location.origin && 'navigate' in client) {
        const focused = await client.focus();
        return focused.navigate(targetUrl);
      }
    }
    // 3. Nothing open → one new window.
    return self.clients.openWindow(targetUrl);
  };

  event.waitUntil(route());
});

Note the two-pass structure. The first pass prefers an exact page match and leaves it untouched, which preserves scroll position and unsaved form state. The second pass reuses any same-origin window through navigate(), so a user with the app open on a different route gets that window brought forward instead of a third tab. openWindow() is the last resort. The same pattern underpins the routing table in adding action buttons to web push notifications, where each action id resolves to its own target URL before entering this tree.

Diagnostic procedure

  1. Confirm the handler runs at all. Add a console.log(event.action, event.notification.data) as the first line, then click the notification with DevTools → Application → Service Workers open. No log means the listener never registered — check for a syntax error above it in sw.js.
  2. Check the worker scope. In the same panel, read the registered scope. If it is not /, any window outside that prefix is permanently invisible to matchAll().
  3. Log what matchAll() actually returns. Print windows.map(c => c.url) before the loop. An empty array with a tab plainly open is the includeUncontrolled bug.
  4. Log the comparison inputs. Print client.url and your resolved targetUrl side by side. A trailing slash, hash or query difference will be obvious immediately.
  5. Verify the promise reaches waitUntil(). Search the handler for event.waitUntil — exactly one call, wrapping the whole chain, with a synchronous (not async) listener signature.
  6. Test cold. Stop the worker with the Stop button in DevTools, then click a notification. This reproduces the mobile cold-start timing where a missing waitUntil() fails every time instead of occasionally.
  7. Test with zero windows open. Close every tab for the origin and click a notification from the tray. Only the openWindow() branch should run, and it should produce exactly one window.

Gotchas and edge cases

  • client.navigate() only works on same-origin, controlled clients. On an uncontrolled client it rejects; focus it and let the page route itself instead.
  • openWindow() rejects cross-origin URLs. If your payload deep-links to a partner domain, land on your own origin and redirect from there.
  • On Android, openWindow() may open the installed web app rather than a browser tab when the origin is installed. That is correct behaviour, not a bug in your handler.
  • focus() can reject if the browser has since dropped the activation. Always .catch() the terminal call so a rejection does not leave the waitUntil() promise unsettled.
  • Duplicate service worker registrations at different scopes produce two workers, and only one of them showed the notification. Register exactly once, at a single scope, as described in Service Worker Registration Patterns.
  • Do not call event.notification.close() after the async work. Close it synchronously at the top, or the card lingers on desktop while your fetch completes.

Back to Notification Display & Actions API

FAQ

Why does clicking my notification open a duplicate tab?

Because clients.openWindow() was called without first checking for an existing window, or because matchAll() was called without includeUncontrolled: true and therefore could not see the tab the user already had open. Enumerate window clients with includeUncontrolled: true, focus a client whose normalised pathname matches your target, and only call openWindow() when no match exists.

Why does nothing happen when I click the notification?

The navigation promise was almost certainly not passed to event.waitUntil(). Once your handler returns, the worker becomes eligible for termination and the transient activation that authorises openWindow() expires, so the call is dropped. Keep the listener synchronous, put the async work in a helper function, and pass that single promise to event.waitUntil().

Should I use client.navigate() or client.focus()?

Both, in order. Focus the client first so the window comes forward, then call navigate() only when the client is already on a different path. Navigating a client that is already on the target page throws away scroll position and any unsaved state for no benefit, and navigate() rejects outright on uncontrolled or cross-origin clients.