How to check favicon of a website
Load the page and read which icon the browser requested, not the ones the HTML declares. link rel="icon", apple-touch-icon and the manifest icons can all disagree, and a page with no markup at all still gets a request for /favicon.ico. The server log is the authority.
Why check this
Run it after a rebrand, after a move to a new static host, and whenever an access log fills with 404s for a file nobody linked. It settles three arguments: which declaration the browser used, what is inside the file, and whether the browser ever asked for the new one.
Four declarations compete: a link rel="icon", an apple-touch-icon, the icons inside the web app manifest, and the implicit /favicon.ico the browser requests from the origin root with no markup present. They can point at four different files, and the page renders the same either way.
The part that costs a morning is the cache. A deployed icon looks unchanged, the request list shows a 200 for it, and nothing reached the server. That status describes a stored response, so only the access log separates a fresh fetch from a cached one.
Prerequisites
- Node 22 and Chrome 120 or later. The browser figures are one capture, Chrome 152.0.7977.76 on Windows 11, on 2026-09-12. Which declarations get fetched is a browser decision.
- The rel=icon reference for the declaration, and the
open()launcher from How to check if a service worker is registered for the driver. - A local site that declares icons three ways, plus a second origin with no
/favicon.ico. Save it asfavicon-demo.mjs. One process serves both ports.
// favicon-demo.mjs: four ways to declare an icon, one origin that has no
// /favicon.ico, and a /deploy route that changes the icon bytes under you.
import { createServer } from 'node:http';
import { deflateSync } from 'node:zlib';
const png = (w, h, rgb) => {
const table = [...Array(256)].map((_, n) => {
let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0;
});
const crc = (b) => { let c = 0xffffffff; for (const x of b) c = table[(c ^ x) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
const chunk = (t, d) => {
const l = Buffer.alloc(4); l.writeUInt32BE(d.length);
const body = Buffer.concat([Buffer.from(t, 'ascii'), d]);
const c = Buffer.alloc(4); c.writeUInt32BE(crc(body));
return Buffer.concat([l, body, c]);
};
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.concat([...Array(h)].map(() =>
Buffer.concat([Buffer.from([0]), ...[...Array(w)].map(() => Buffer.from(rgb))])));
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
};
// an .ico holding three PNG images, the way a real favicon.ico is built
const ico = (imgs) => {
const dir = Buffer.alloc(6); dir.writeUInt16LE(0, 0); dir.writeUInt16LE(1, 2); dir.writeUInt16LE(imgs.length, 4);
let offset = 6 + imgs.length * 16;
const entries = imgs.map(({ size, data }) => {
const e = Buffer.alloc(16);
e[0] = size % 256; e[1] = size % 256; e.writeUInt16LE(1, 4); e.writeUInt16LE(32, 6);
e.writeUInt32LE(data.length, 8); e.writeUInt32LE(offset, 12); offset += data.length;
return e;
});
return Buffer.concat([dir, ...entries, ...imgs.map((i) => i.data)]);
};
const COLOURS = [[15, 118, 110], [185, 28, 28]];
let version = 0; // /deploy swaps the icon bytes
const head = `<link rel="icon" href="/icons/site-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/icons/apple-180.png">
<link rel="manifest" href="/fav.webmanifest">`;
const page = (h, title) => `<!doctype html><meta charset="utf-8"><title>${title}</title>${h}<h1>${title}</h1>`;
const manifest = JSON.stringify({ name: 'Icon demo', start_url: '/', display: 'standalone',
icons: [{ src: '/icons/manifest-192.png', sizes: '192x192', type: 'image/png' }] });
const handler = (req, res) => {
const port = req.socket.localPort;
console.log(`${new Date().toISOString().slice(11, 19)} :${port} GET ${req.url}` +
(req.url.startsWith('/icons/site-32') ? ` -> v${version}` : ''));
const send = (type, body, cache = 'max-age=600') => {
res.writeHead(200, { 'content-type': type, 'cache-control': cache }).end(body);
};
if (req.url === '/declared/') return send('text/html; charset=utf-8', page(head, 'Declared'), 'no-store');
if (req.url === '/bare/') return send('text/html; charset=utf-8', page('', 'Bare'), 'no-store');
if (req.url === '/versioned/') return send('text/html; charset=utf-8',
page('<link rel="icon" href="/icons/site-32.png?v=2" type="image/png">', 'Versioned'), 'no-store');
if (req.url === '/fav.webmanifest') return send('application/manifest+json', manifest);
if (req.url.startsWith('/icons/site-32.png')) return send('image/png', png(32, 32, COLOURS[version]));
if (req.url === '/icons/apple-180.png') return send('image/png', png(180, 180, [30, 64, 175]));
if (req.url === '/icons/manifest-192.png') return send('image/png', png(192, 192, [161, 98, 7]));
if (req.url === '/deploy') { version = (version + 1) % 2; return send('text/plain', 'icon is now v' + version, 'no-store'); }
if (req.url === '/favicon.ico' && port === 8793) {
return send('image/x-icon', ico([16, 32, 48].map((s) => ({ size: s, data: png(s, s, [124, 45, 18]) }))));
}
res.writeHead(404, { 'content-type': 'text/plain' }).end('not found');
};
for (const port of [8793, 8794]) {
createServer(handler).listen(port, () => console.log(`serving http://localhost:${port}/ (8794 has no /favicon.ico)`));
}
- A size reader,
icon-sizes.mjs. An.icois a container, so its entry table is the answer about size.
// icon-sizes.mjs <url>: the real dimensions of an icon, read from its bytes.
const r = await fetch(process.argv[2]);
const b = Buffer.from(await r.arrayBuffer());
console.log(`${process.argv[2]} ${r.status} ${r.headers.get('content-type')} ${b.length} B cache-control: ${r.headers.get('cache-control')}`);
if (b.subarray(1, 4).toString() === 'PNG') {
console.log(` PNG ${b.readUInt32BE(16)}x${b.readUInt32BE(20)}`);
} else if (b.readUInt16LE(0) === 0 && b.readUInt16LE(2) === 1) {
const n = b.readUInt16LE(4);
console.log(` ICO with ${n} image(s)`);
for (let i = 0; i < n; i++) {
const e = 6 + i * 16;
const w = b[e] || 256, h = b[e + 1] || 256, len = b.readUInt32LE(e + 8), off = b.readUInt32LE(e + 12);
const kind = b.subarray(off + 1, off + 4).toString() === 'PNG' ? 'PNG' : 'BMP';
console.log(` ${w}x${h} ${len} B ${kind}`);
}
} else console.log(' not a PNG and not an ICO');
- A driver,
favicon-run.mjs, that loads each page, waits 1500 ms, and prints the icon requests its page target recorded between one phase and the next.
Steps
- Step 1.
Start the two origins.
node favicon-demo.mjsserving http://localhost:8793/ (8794 has no /favicon.ico) serving http://localhost:8794/ (8794 has no /favicon.ico) - Step 2.
Read every icon declaration the page carries.
[...document.querySelectorAll('link')] .map((l) => `rel=${l.rel} sizes=${l.getAttribute('sizes') ?? '-'} href=${l.getAttribute('href')}`)--- 2 every icon declaration in that page [ "rel=icon sizes=32x32 href=/icons/site-32.png", "rel=apple-touch-icon sizes=- href=/icons/apple-180.png", "rel=manifest sizes=- href=/fav.webmanifest" ]Three declarations, three different files, and the manifest adds a fourth icon of its own.
- Step 3.
Read which of them the browser fetched on the first load.
node favicon-run.mjs--- 1 first load of /declared/ [ "200 other :8793/icons/site-32.png", "200 other :8793/icons/manifest-192.png" ]Two of the four. The
apple-touch-iconwas declared and never requested, and/favicon.icowas not requested either. - Step 4.
Read the sizes out of the bytes rather than out of the markup.
node icon-sizes.mjs http://localhost:8793/favicon.icohttp://localhost:8793/favicon.ico 200 image/x-icon 355 B cache-control: max-age=600 ICO with 3 image(s) 16x16 79 B PNG 32x32 99 B PNG 48x48 123 B PNGOne URL, three images, and the browser picks one. A
sizesattribute cannot describe this file. - Step 5.
Change the icon on the server, then reload the page twice, the second time with the cache disabled.
await s.cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); await s.page.reload({ waitUntil: 'networkidle2' });--- 3 icon is now v1 --- 4 after a normal reload [ "200 other :8793/icons/site-32.png", "200 other :8793/icons/manifest-192.png" ] --- 5 after a reload with the cache disabled [ "200 other :8793/icons/site-32.png", "200 other :8793/icons/manifest-192.png" ]Four 200 responses for an icon that changed on the server. Step 8 shows that none of them reached it.
- Step 6.
Clear the browser cache, reload, and then load a page that declares the same file under a new URL.
await s.cdp.send('Network.clearBrowserCache'); await s.page.reload({ waitUntil: 'networkidle2' });--- 6 after Network.clearBrowserCache and a reload [ "200 other :8793/icons/site-32.png", "200 other :8793/icons/manifest-192.png" ] --- 7 a page that declares the same file at a new URL [ "200 other :8793/icons/site-32.png?v=2" ]These lines look like the two above and are not: both requests reached the server.
- Step 7.
Load a page with no icon markup whatsoever, on each of the two origins.
await s.goto('http://localhost:8793/bare/'); await s.goto('http://localhost:8794/bare/');--- 8 a page with no icon markup, origin has /favicon.ico [ "200 other :8793/favicon.ico" ] --- 9 the same page on an origin with no /favicon.ico [ "404 other :8794/favicon.ico" ]Nothing in either page mentions an icon. The browser asked the origin root anyway, and on the second origin that request is the 404 in the access log.
- Step 8.
Read the server's own log for the whole run.
cat favicon-server.log20:59:03 :8793 GET /declared/ 20:59:03 :8793 GET /fav.webmanifest 20:59:03 :8793 GET /icons/site-32.png -> v0 20:59:03 :8793 GET /icons/manifest-192.png 20:59:05 :8793 GET /deploy 20:59:05 :8793 GET /declared/ 20:59:08 :8793 GET /declared/ 20:59:11 :8793 GET /declared/ 20:59:11 :8793 GET /icons/site-32.png -> v1 20:59:11 :8793 GET /fav.webmanifest 20:59:11 :8793 GET /icons/manifest-192.png 20:59:13 :8793 GET /versioned/ 20:59:13 :8793 GET /icons/site-32.png?v=2 -> v1 20:59:16 :8793 GET /bare/ 20:59:16 :8793 GET /favicon.ico 20:59:18 :8794 GET /bare/ 20:59:18 :8794 GET /favicon.icoRead the gap. Between 20:59:05 and 20:59:11 the page was reloaded twice, once with the cache disabled, and no icon request arrived. The new bytes went out only after the cache was cleared at 20:59:11 and again under the new URL at 20:59:13.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| A 200 in the request list and nothing in the access log | The icon came from the browser cache | Clear the cache or change the URL before judging it |
| 404 /favicon.ico from a page with no markup | The browser asked the origin root on its own | Serve a file there or accept the 404 in the log |
| ICO with 3 image(s) | One URL holds several sizes | Read the entry table, not the sizes attribute |
| An apple-touch-icon that is never requested | Desktop Chrome has no use for it | Check it on the platform that consumes it |
| The manifest icon fetched next to the link icon | Two declarations are live at once | Keep them the same image, or expect them to diverge |
| content-type: text/html on the icon URL | The path falls through to an SPA or a 404 page | Fix the route before looking at the image |
Common mistakes
What to check next
- How to check web manifest: the manifest icons are a separate declaration with their own sizes, measured the same way.
- How to check cache-control header: the header that decides how long the old icon stays on every machine that saw it.
- How to find broken images on a website: the same request-and-measure pass for the images in the body.
- How to test offline mode in chrome devtools: a service worker can serve the old icon long after the cache is cleared.
- How to check console errors on a website: where a blocked or mistyped icon URL shows up while the page still renders.
FAQ
How to check favicon size?
Fetch the file and read its header, as in step 4. A PNG carries its dimensions in the first chunk, and an .ico carries an entry per image, so one URL answers 16, 32 and 48 at once.
Why is my favicon not showing in chrome?
Ask the server, not the browser. The request either never happened, returned a 404, or returned HTML from a fallback route. Step 8 separates the three, and a cached old icon looks identical to all of them in the request list.
How to force a favicon refresh?
Change the URL. Adding ?v=2 produced a fresh request in step 6, while a reload and a cache-disabled reload did not. Clearing the browser cache also worked, but it only fixes the one machine in front of you.
Does a site need /favicon.ico when it declares an icon?
The request stops when a declaration is present: on the page with a link rel="icon" the browser never asked for it. Without one it asks every time, so a file at the origin root keeps the 404s out of the log.
Verified
Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
basic6 minpublished updated Maks Verny