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

// 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)`));
}
// 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');

Steps

  1. Step 1.

    Start the two origins.

    node favicon-demo.mjs
    
    serving http://localhost:8793/  (8794 has no /favicon.ico)
    serving http://localhost:8794/  (8794 has no /favicon.ico)
  2. 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.

  3. 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-icon was declared and never requested, and /favicon.ico was not requested either.

  4. Step 4.

    Read the sizes out of the bytes rather than out of the markup.

    node icon-sizes.mjs http://localhost:8793/favicon.ico
    
    http://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  PNG

    One URL, three images, and the browser picks one. A sizes attribute cannot describe this file.

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

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

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

  8. Step 8.

    Read the server's own log for the whole run.

    cat favicon-server.log
    
    20: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.ico

    Read 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

Sign: A new favicon is deployed and every browser still shows the old one.Cause: The icon is served from the HTTP cache and the request list still shows 200. In this capture a normal reload and a reload with the cache disabled both reported 200 while the server received nothing at all.
Sign: The access log fills with 404s for /favicon.ico on a site that never links one.Cause: With no icon declaration the browser requests /favicon.ico from the origin root. The request is made by the browser, not by the page, so no amount of reading the HTML explains it.
Sign: Disabling the cache in the Network panel does not bring the new icon back.Cause: The favicon is fetched outside the page's own network path. In this capture the cache-disabled setting on the page target changed nothing, and only clearing the browser cache produced a fresh request.
Sign: An icon declares sizes=32x32 and the wrong size is displayed.Cause: sizes is text the browser may use to choose between declarations, never a description of the file. An .ico holds several images, and the browser picks from inside it.

What to check next

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.

basic6 minpublished updated Maks Verny