Notification Icon and Badge Rendering Differences
You pass one icon, one badge and one image URL to showNotification(), and three platforms draw three different notifications — or draw nothing at all where you expected artwork.
Quick answer
The browser does not composite the notification. It hands your asset URLs to the host operating system’s notification service, which applies its own geometry, its own scaling and, on Android, its own colour treatment. Ship a 192 × 192 PNG for icon, a 96 × 96 alpha-only silhouette for badge, and a 2:1 raster around 1350 × 675 for image. Serve all three over HTTPS from a publicly reachable URL with no authentication, and never use SVG.
| Asset | Ship this | Format | Aspect | Rendered by |
|---|---|---|---|---|
icon |
192 × 192 | 24-bit PNG with alpha | 1:1 | every engine that draws artwork at all |
badge |
96 × 96 | PNG, meaningful alpha channel only | 1:1 | Chromium and Firefox on Android |
image |
1350 × 675 | PNG or JPEG, under 200 KB | 2:1 | Chromium on Windows, ChromeOS and Android |
action icon |
128 × 128 | PNG, single-weight glyph | 1:1 | Chromium and Firefox on Android |
Those numbers come from device pixel ratio, not from taste. Android draws the large notification icon at 48–64 density-independent pixels; on an xxxhdpi phone at DPR 4 that is up to 256 physical pixels, and 192 is the pragmatic floor before the upscale becomes visible. The badge is drawn at 24 dp, which is 96 physical pixels at DPR 4. Supply less and the platform stretches it; supply a 1024 × 1024 icon and every device pays the decode cost to throw 95% of the pixels away.
Three platforms, three compositors
Windows draws a toast through the Action Center: the icon sits on the left at roughly 48 CSS pixels, the title and body run beside it, and image becomes a hero band underneath. macOS hands the notification to the system Notification Center, which shows the browser’s application icon as the notification’s identity and attaches your icon as a small thumbnail on the right — there is no hero image slot and no badge slot at all. Android composites into the system tray: a monochrome badge glyph appears in the status bar, the icon becomes the large icon on the right of the collapsed row, and image expands into a big-picture view when the user pulls the shade down.
The practical consequence is that artwork can never carry information. A badge that encodes a category by colour is meaningless on Android and invisible on macOS. A hero image with text baked into the lower third is cropped on Android’s big-picture view. Keep meaning in the title, the body and the action buttons where they are supported, and treat imagery as reinforcement.
The badge is a mask, not a picture
This is the single most surprising behaviour. On Android the badge asset is not drawn as an image. The platform reads only its alpha channel, discards every colour value, and stamps the resulting silhouette into the status bar in whatever tint the system theme dictates — usually pure white on a dark bar, or the notification’s accent colour on some OEM skins. A full-colour brand mark with a white background therefore renders as a solid white square, because the opaque background is exactly what the alpha channel preserves.
Design the badge as a single-weight glyph on a transparent canvas, with no gradients, no interior detail below about 8 physical pixels, and generous padding — the silhouette is drawn at 24 dp, so hairlines disappear. If you cannot reduce your brand mark to a readable silhouette at that size, ship a simplified pictogram instead of a shrunken logo. Omitting badge altogether is better than shipping a white rectangle: Chromium falls back to a generic browser glyph, which at least reads as a notification rather than as a rendering bug.
Why an SVG icon usually fails
Notification artwork is fetched and decoded outside the page. In Chromium the request is issued from the browser process and the bytes are handed to the image decoder in a sandboxed utility process that has no layout engine, no CSS cascade and no scripting. SVG is not an image format in that context — it is a document that needs a renderer — so the decode fails and the notification is drawn without the asset. There is no console message in the page, because the page was never involved.
Even where an engine does rasterise SVG for notifications, the result is unreliable: an SVG with no intrinsic width and height has no natural size to rasterise at, currentColor resolves against nothing, external fonts never load, and @media (prefers-color-scheme: dark) inside the file evaluates against a context you do not control. Convert to PNG at build time. If you want a single source of truth, keep the SVG in the repository and generate the 96, 128 and 192 pixel PNGs in your build pipeline.
The same reasoning explains a subtler failure: data: URLs work for small icons in Chromium but are subject to a length ceiling and are not supported uniformly, and blob: URLs never work because the blob’s origin is the page, which the browser-process fetch cannot reach.
Fetch rules: HTTPS, credentials and CORS
Every asset URL is fetched by the browser, not by your service worker. Four rules follow from that.
The URL must be HTTPS. Web push already requires a secure context, and an http: asset inside a page delivered over https: is blocked as mixed content before the request leaves the machine.
The fetch is anonymous. It is a no-credentials GET issued from the browser process, so session cookies, Authorization headers and signed-cookie CDN protection do not apply. An icon behind a login returns 403 and vanishes. Host notification artwork on a public path or a public CDN bucket.
Your service worker’s fetch handler does not see it. Because the request originates in the browser process rather than from a client under the worker’s control, you cannot serve notification artwork from the Cache API by intercepting it. Pre-caching the icon does nothing for the notification; it only helps the page that shows the same image.
CORS is mostly not required — the request is a no-cors image load in Chromium, and the response is opaque to script — but two things still bite. Some hardened CDN configurations reject requests with no Origin header, and a Cross-Origin-Resource-Policy: same-origin response header will cause the load to be rejected. If your assets sit behind a CDN that sets CORP globally, set Cross-Origin-Resource-Policy: cross-origin on the notification asset path.
Building one asset set that works everywhere
- Export a square master at 512 × 512 PNG with a transparent background and at least 8% padding inside the canvas, so platforms that apply a circular crop do not clip your mark.
- Downscale to 192 × 192 for
iconwith a Lanczos or similar filter, then check it at 48 CSS pixels on a non-retina display — that is the smallest size any desktop will draw it at. - Author the badge separately at 96 × 96. Flatten to one shape, delete every fill colour, and preview it as pure white on a black background. If it is unreadable there, it is unreadable in the status bar.
- Produce the hero at 1350 × 675, keep every important element inside the middle 80% horizontally, and compress under 200 KB — this asset is fetched on the notification’s critical path and a slow response loses it.
- Serve all four from a public, cacheable HTTPS path with a long
Cache-Control: max-ageand a content hash in the filename, and confirm the responses carry noCross-Origin-Resource-Policy: same-originheader. - Verify by URL, not by eye. Open each asset URL in a private window with no session. If it renders there, the browser process can fetch it.
// service-worker.js — one asset set, hashed and public
const ASSETS = {
icon: 'https://cdn.example.com/push/icon-192.f4a2c1.png',
badge: 'https://cdn.example.com/push/badge-96.9b17de.png',
};
self.addEventListener('push', (event) => {
const msg = event.data ? event.data.json() : {};
const options = {
body: msg.body,
tag: msg.tag || 'default',
data: { url: msg.url },
icon: ASSETS.icon,
badge: ASSETS.badge,
};
// Only attach a hero when the message actually ships one; a broken
// image URL costs a fetch attempt on the notification's critical path.
if (msg.image && msg.image.startsWith('https://')) {
options.image = msg.image;
}
event.waitUntil(self.registration.showNotification(msg.title, options));
});
Gotchas and edge cases
- A missing icon is not a blank notification. If the title and body are also empty you have a payload problem, not an asset problem — see why Chrome shows a blank push notification.
- Safari substitutes your web app’s icon. On macOS and in an installed iOS web app the notification always shows the icon declared in the web app manifest, not the
iconyou pass, so keep the manifest icons current. The Safari and iOS integration reference covers the manifest requirements. - Relative URLs resolve against the service worker’s scope.
'/icons/badge.png'works;'icons/badge.png'resolves relative to the worker script’s location, which is rarely what you meant. - Asset URLs count against the 4 KB payload budget. Icon, badge, image and per-action icon URLs all travel inside the
aes128gcm-encrypted body that RFC 8291 caps at 4 KB. Long signed CDN URLs with query strings can consume several hundred bytes each. - OEM Android skins re-tint everything. Samsung One UI, MIUI and ColorOS each apply their own icon shape mask and accent colour to the badge. Test on at least one non-Pixel device before assuming your silhouette survives.
Related
- Cross-Browser Web Push Quirks — the wider set of rendering and delivery differences between Chromium, Gecko and WebKit.
- Why Chrome Shows a Blank Push Notification — the failure mode that looks like a missing icon but is a missing payload.
- Which Browsers Support Notification Actions — the support matrix for the display options that surround these assets.
- Core Protocols & Browser Implementation — the protocol layer that carries these URLs to the device.
Back to Cross-Browser Web Push Quirks
FAQ
What size should a web push notification icon be?
Ship 192 by 192 pixels as a 24-bit PNG with an alpha channel. Android draws the large icon at up to 64 density-independent pixels, which is 256 physical pixels on a device with a pixel ratio of 4, and 192 is the practical floor before upscaling becomes visible. Desktop platforms draw it far smaller, around 48 CSS pixels, so check the mark is still legible at that size.
Why is my badge showing as a solid white square on Android?
Because Android reads only the alpha channel of the badge and discards all colour. An opaque background — including a white one — is fully opaque in the alpha channel, so the entire canvas is stamped into the status bar as a filled shape. Re-export the badge as a single flat glyph on a fully transparent background, with no colour information and no gradients.
Can I use an SVG for the notification icon?
No, in practice. Chromium fetches notification artwork in the browser process and decodes it in a sandboxed image decoder with no layout engine, so an SVG has nothing to rasterise it. The decode fails and the notification is drawn without the icon, with no error surfaced to the page. Generate PNGs from the SVG at build time at 96, 128 and 192 pixels instead.