Why Does Chrome Show a Blank Push Notification?

A blank Chrome notification — empty title, “This site has been updated in the background”, or a faceless tile with no icon — means your push handler ran but showNotification() got nothing usable to render.

Quick answer

Chrome shows a blank or generic notification when the service worker’s push event fires but fails to produce a valid notification. The four usual causes are: the push arrived with no payload data (so event.data is null), the payload failed to parse as JSON inside the handler, the browser auto-generated a fallback because you sent a silent push without showing your own notification (the userVisibleOnly budget), or the notification rendered but with a missing/unreachable icon. The fix is a defensive push handler that always supplies a title and body and never throws.

Why this happens

When a push message reaches Chrome, the browser wakes your service worker and dispatches a push event. Chrome enforces a rule rooted in the userVisibleOnly: true contract every Chromium subscription is created with: each push must result in a user-visible notification. If your handler throws, returns before calling showNotification(), or finishes without one, Chrome steps in and displays its own generic “site updated in the background” tile so the user is not deceived by an invisible push. That fallback tile is the classic “blank” notification.

The other path to a blank tile is your own code calling showNotification(undefined) or showNotification('') because the expected fields were not in the payload. This commonly happens when the payload was sent without a body, when event.data.json() throws on a non-JSON string and the catch block shows an empty notification, or when the icon URL 404s and Chrome renders the tile without it. For the full per-vendor picture of how Chromium, Gecko, and WebKit diverge here, see the cross-browser notification quirks reference and the broader Core Protocols & Browser Implementation overview.

It helps to separate two failure classes that look identical to the user. The first is a substituted notification: Chrome rendered its own generic “This site has been updated in the background” tile because your handler never produced one. The second is a degraded notification: your handler ran, called showNotification(), but with empty strings or an unreachable icon, so the tile shows but looks broken. The diagnostic that distinguishes them is whether the tile shows your branding at all — if you see the generic Chrome wording, the handler failed to render; if you see your icon slot but no text, the payload was empty. Knowing which class you are in tells you whether to fix the server (missing payload), the handler (parse/throw), or the assets (icon URL).

Chrome’s substitution behavior is deliberate and tied to the userVisibleOnly: true flag that every Chromium subscription must carry. By accepting that flag the origin promises that each push produces something the user can see. If a push arrives and no notification is shown within the event’s lifetime, the browser treats the promise as broken and either substitutes a tile or, after repeated offenses, debits your push budget. The budget is a quota Chromium maintains per origin; once exhausted, subsequent pushes can be dropped entirely even when your handler is correct, which makes a transient bug compound into a persistent one.

Defensive push handler

The handler below never throws, always renders a real title and body, parses defensively, and only references an icon you know exists. This single pattern eliminates the large majority of blank-notification reports.

self.addEventListener('push', (event) => {
  const showFallback = () =>
    self.registration.showNotification('New notification', {
      body: 'Tap to open the app.',
      icon: '/icons/push-192.png',
      tag: 'fallback',
    });

  const handle = async () => {
    // 1. No payload at all -> event.data is null
    if (!event.data) {
      return showFallback();
    }

    // 2. Parse defensively: a non-JSON body would throw on .json()
    let data;
    try {
      data = event.data.json();
    } catch {
      // Fall back to the raw text so the push is never silent
      data = { title: 'Update', body: event.data.text() };
    }

    // 3. Guarantee user-visible content so Chrome never substitutes its own tile
    const title = data.title || 'New notification';
    await self.registration.showNotification(title, {
      body: data.body || 'You have a new update.',
      icon: data.icon || '/icons/push-192.png', // must be a reachable, same-origin URL
      badge: '/icons/badge-72.png',
      tag: data.tag || 'default',
    });
  };

  // 4. Keep the worker alive until the notification is shown
  event.waitUntil(handle().catch(showFallback));
});

On the server, make sure you are actually sending a payload. An empty webpush.sendNotification(subscription) with no data argument delivers a push with no body, which is the number-one cause of a genuinely empty tile.

const webpush = require('web-push');
webpush.setVapidDetails(
  'mailto:ops@yourdomain.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

// Always send a payload with at least a title and body
await webpush.sendNotification(
  subscription,
  JSON.stringify({ title: 'Order shipped', body: 'Your order is on the way.' }),
  { TTL: 86400 }
);

The handler-lifetime trap

A blank tile can appear even when your handler code is correct, if the notification is not shown before the push event’s lifetime ends. The browser keeps your service worker alive only as long as a promise passed to event.waitUntil() is pending. If you call showNotification() outside waitUntil(), or if you await it but never pass the resulting promise to waitUntil(), the browser may terminate the worker the instant the synchronous part of your handler returns — before the notification renders. Chrome then substitutes its generic tile because, from its perspective, the push produced nothing in time. The fix is the pattern shown above: build a single async function that performs all the work including showNotification(), and pass exactly that promise to event.waitUntil(). Fetching extra content (an avatar, a count) inside the handler compounds this risk, because a slow network call can outlast the event budget; fetch quickly or render first with placeholder data and update afterward.

Diagnostic steps

  1. Open the service worker console. In Chrome DevTools, go to Application → Service Workers, then use “Push” to send a test payload, or watch the console while a real push arrives. A thrown error in the push handler appears here and confirms the fallback-tile cause.
  2. Check whether event.data is null. Log event.data && event.data.text() at the top of the handler. If it is null, the server is sending a push with no payload — fix the send call.
  3. Verify the payload parses. If event.data.text() is not valid JSON (for example a plain string or an HTML error page), event.data.json() throws. Wrap it in try/catch as shown above.
  4. Confirm the icon resolves. Paste the icon URL into the address bar. If it 404s or is cross-origin without CORS, the tile renders without an icon and looks “blank”. Use a same-origin 192×192 PNG.
  5. Audit the silent-push budget. If you are sending pushes with userVisibleOnly: false or finishing the handler without showNotification(), Chrome shows its generic tile and may eventually suppress your pushes. Ensure every push shows a notification.
  6. Inspect chrome://gcm-internals to confirm the message was delivered to the device, ruling out a delivery failure versus a rendering failure.

Contrasting the four root causes

Symptom Root cause Where to fix
Generic “site updated in background” tile Handler finished without showNotification(), or threw Service worker push handler
Tile with your icon but no title/body Payload sent with empty or missing fields Server send call / payload schema
Tile renders, icon slot empty Icon URL 404s, is cross-origin without CORS, or wrong size Notification icon asset
No tile at all, later pushes vanish Silent push abused the userVisibleOnly budget Stop sending invisible pushes; show one per push

Reading the symptom column first narrows the search dramatically. A generic tile is always a handler problem; an empty-text tile is always a payload problem. Do not start changing icon assets when the tile wording tells you the handler never ran.

Gotchas and edge cases

  • event.data.json() is not async but it throws synchronously. If you await it or forget the try/catch, an unhandled rejection ends the handler before showNotification() runs, and Chrome shows its fallback.
  • A 404 icon does not error — it just disappears. The notification still renders, so the tile looks half-blank rather than failing loudly. Always test the icon URL directly.
  • Sending no payload is valid but rarely intended. A bare push wakes the worker with event.data === null; if your handler assumes data exists, you get an empty or fallback tile.
  • The silent-push budget is per-origin and recovers slowly. Once Chrome flags you for invisible pushes, even correct notifications can be suppressed for a while. Never ship userVisibleOnly: false unless you have a documented allowance.
  • Cross-origin icons need CORS. An icon hosted on a CDN without permissive headers may be dropped; prefer same-origin assets for notification icons and badges.

Back to Cross-Browser Notification Quirks