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).

Correct, substituted and degraded notification tiles A correct tile shows your icon, title and body. A substituted tile shows Chrome's own site-updated-in-the-background wording because the handler never called showNotification. A degraded tile shows your notification with empty text or a missing icon because the payload fields were absent. Read the tile first — its wording names the failing layer Correct Order shipped Tap to track your order yoursite.com · now Substituted by Chrome ? This site has been updated in the background yoursite.com · now Degraded yoursite.com · now Handler rendered in time showNotification() ran inside the waitUntil() promise nothing to fix Handler never rendered it threw, returned early, or was terminated too soon fix the service worker Fields or assets missing empty title/body, or an icon that 404s or is cross-origin fix the payload or the asset Chrome's own wording means the handler failed; your branding with no text means the payload did.
The same complaint — "blank notification" — has three visually distinct forms, each pointing at a different layer.

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. The engine-by-engine size of that budget is charted in the cross-browser notification quirks guide — on mobile WebKit it is a few seconds, not thirty.

Worker lifetime with and without waitUntil With waitUntil the worker stays alive until showNotification resolves and the tile renders. Without it the worker is terminated as soon as the synchronous part of the handler returns, showNotification never completes, and Chrome substitutes its own tile. The worker lives exactly as long as the promise you hand to waitUntil() With waitUntil() worker alive — promise pending showNotification() resolves · tile renders push event dispatched Without waitUntil() sync part runs worker already terminated showNotification() never completes Chrome substitutes its generic tile here t0 time Build one async function that ends in showNotification(), and pass exactly that promise to waitUntil().
Same handler code, two lifetimes: only the promise handed to waitUntil() holds the worker open long enough to render.

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.

Run the checks in that order and each one either names the cause or eliminates a layer. If all four come back clean, the remaining explanation is the handler-lifetime trap above.

Four checks that isolate a blank notification Check whether event.data is null, whether the body parses as JSON, whether the icon URL resolves, and whether the userVisibleOnly budget is intact. Each failing check names a distinct fix; if all four pass, the handler finished without calling showNotification. Narrow a blank tile in four checks, in this order 1 · event.data null? log it first thing 2 · body parses? json() throws sync 3 · icon resolves? open the URL 4 · budget intact? gcm-internals No payload sent Fix the server send call, not the worker Non-JSON body Wrap json() in a try/catch, use text() Icon 404 or CORS Ship a same-origin 192×192 PNG Budget debited Show one notification for every push All four clean? Then the handler finished without showNotification() Chrome substituted its own tile — pass the full async chain to event.waitUntil(). Repeated offences debit the per-origin push budget and suppress later pushes.
Each check either names the cause or eliminates a layer; a clean sweep points back at worker lifetime.

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, and check how each engine scales and masks them in notification icon and badge rendering differences.

Back to Cross-Browser Notification Quirks

FAQ

Why does Chrome show "This site has been updated in the background"?

That is Chrome’s substituted tile. Every Chromium subscription carries userVisibleOnly: true, so if the push event ends without your code calling showNotification() — because the handler threw, returned early, or the worker was terminated before the promise settled — the browser displays its own generic tile rather than let an invisible push through. Fix it by building one async function that ends in showNotification() and passing that promise to event.waitUntil().

Can a missing icon alone make a notification look blank?

Yes, partially. A 404 or cross-origin icon does not raise an error; the tile renders without the image, which reads as broken rather than failing loudly. If the title and body are also empty because the payload lacked those fields, the tile looks entirely blank. Test the icon URL directly in the address bar and prefer a same-origin 192×192 PNG.

Does one blank notification affect future pushes?

A single incident does not, but a pattern does. Chromium maintains a per-origin push budget, and repeatedly ending a push event without a visible notification debits it. Once exhausted, later pushes can be dropped even when your handler is correct, which turns a transient bug into a persistent delivery problem. Never ship userVisibleOnly: false without a documented allowance.