How to check web manifest
Fetch the file the page's link rel="manifest" points at, parse it, and read name, short_name, start_url against scope, display and the icons. Fetch every icon and measure it, because sizes is a claim. Then compare your reading with the one Chrome made.
Why check this
Run this on staging sign-off for anything installable, and after any change to routing, to the icon pipeline, or to the login wall in front of the application. The manifest is read at install time, so a defect in it ships quietly and returns as a ticket about the icon or about the app opening on the wrong screen.
Two defects survive every syntax check. An icon whose sizes value disagrees with the image behind it parses as valid JSON, and a start_url outside scope parses as valid JSON as well. Both change what the installed application does, and neither is visible in the file.
The third failure is not in the manifest at all. The request for it carries no cookies, so an application that answers unauthenticated requests with a login page returns HTML here, and the parse fails with a message about a line and a column.
Prerequisites
- Node 22, Chrome 120 or later, and curl. The browser figures are one capture, Chrome 152.0.7977.76 on Windows 11, on 2026-09-12.
- The Web app manifest reference for the member names, and Page.getAppManifest for the call behind the Manifest pane in the DevTools Application panel.
- A local site with two manifests, one broken in both ways and one corrected. Save it as
manifest-demo.mjsand stop it by PID afterwards.
// manifest-demo.mjs: a page whose manifest parses and is still wrong, and a
// second manifest that sits behind a login. Logs the Cookie header it received.
import { createServer } from 'node:http';
import { deflateSync } from 'node:zlib';
// a real PNG of the given size, so the bytes on the wire have true dimensions
const png = (w, h, rgb) => {
const crcTable = [...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 byte of b) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type, data) => {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const c = Buffer.alloc(4); c.writeUInt32BE(crc(body));
return Buffer.concat([len, body, c]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; ihdr[9] = 2; // 8-bit, truecolour
const raw = Buffer.concat([...Array(h)].map(() =>
Buffer.concat([Buffer.from([0]), Buffer.concat([...Array(w)].map(() => Buffer.from(rgb)))])));
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)),
]);
};
const manifest = JSON.stringify({
name: 'Orders Console',
short_name: 'Orders',
start_url: '/dashboard/',
scope: '/app/',
display: 'standalone',
theme_color: '#0f766e',
background_color: '#ffffff',
icons: [
{ src: '/icons/logo-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/logo-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
],
}, null, 2);
const fixed = JSON.stringify({
name: 'Orders Console',
short_name: 'Orders',
start_url: '/app/dashboard/',
scope: '/app/',
display: 'standalone',
theme_color: '#0f766e',
background_color: '#ffffff',
icons: [{ src: '/icons/logo-192.png', sizes: '192x192', type: 'image/png' }],
}, null, 2);
const html = (link) => `<!doctype html><meta charset="utf-8"><title>Orders Console</title>
${link}<h1>Orders Console</h1>`;
const login = `<!doctype html><meta charset="utf-8"><title>Sign in</title>
<h1>Sign in</h1><form method="post" action="/login"><input name="user"></form>`;
const routes = {
'/': ['text/html; charset=utf-8', html('<link rel="manifest" href="/app.webmanifest">')],
'/creds/': ['text/html; charset=utf-8',
html('<link rel="manifest" href="/app.webmanifest" crossorigin="use-credentials">')],
'/private/': ['text/html; charset=utf-8', html('<link rel="manifest" href="/private/app.webmanifest">')],
'/app.webmanifest': ['application/manifest+json', manifest],
'/fixed/': ['text/html; charset=utf-8', html('<link rel="manifest" href="/fixed.webmanifest">')],
'/fixed.webmanifest': ['application/manifest+json', fixed],
'/icons/logo-192.png': ['image/png', png(192, 192, [15, 118, 110])],
'/icons/logo-512.png': ['image/png', png(192, 192, [180, 83, 9])], // declared 512, really 192
};
createServer((req, res) => {
console.log(`${new Date().toISOString().slice(11, 19)} ${req.method} ${req.url} cookie=${req.headers.cookie ?? '-'}`);
if (req.url === '/private/app.webmanifest') { // behind a login, like the app
if (!/session=/.test(req.headers.cookie ?? '')) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(login);
return;
}
res.writeHead(200, { 'content-type': 'application/manifest+json' }).end(manifest);
return;
}
const hit = routes[req.url];
if (!hit) { res.writeHead(404).end('not found'); return; }
if (hit[0].startsWith('text/html')) res.setHeader('set-cookie', 'session=abc123; Path=/');
res.setHeader('content-type', hit[0]);
res.end(hit[1]);
}).listen(8792, () => console.log('serving http://localhost:8792/'));
- The audit script,
manifest-audit.mjs. It takes a page URL, not a manifest URL, because finding the declaration is half the check.
// manifest-audit.mjs <page-url>: reads the manifest the page declares and checks
// the fields that decide whether it is usable. Icon sizes come from the bytes.
const pageUrl = process.argv[2];
const html = await (await fetch(pageUrl)).text();
const link = /<link[^>]+rel=["']?manifest["']?[^>]*>/i.exec(html);
if (!link) { console.log('no <link rel="manifest"> in ' + pageUrl); process.exit(1); }
const href = /href=["']([^"']+)["']/i.exec(link[0])[1];
const url = new URL(href, pageUrl);
const cors = /crossorigin=["']?([^"'\s>]+)/i.exec(link[0])?.[1] ?? 'absent';
console.log(`manifest ${url.href} crossorigin=${cors}`);
const res = await fetch(url);
console.log(`status ${res.status} content-type ${res.headers.get('content-type')}`);
const body = await res.text();
let man;
try { man = JSON.parse(body); } catch (e) {
console.log(`JSON.parse failed: ${e.message}`);
console.log(`body starts: ${body.slice(0, 48).replace(/\n/g, ' ')}`);
process.exit(1);
}
for (const f of ['name', 'short_name', 'start_url', 'scope', 'display', 'theme_color'])
console.log(`${f}: ${man[f] === undefined ? 'MISSING' : JSON.stringify(man[f])}`);
const scope = new URL(man.scope ?? './', url);
const start = new URL(man.start_url ?? './', url);
console.log(`start_url resolves to ${start.href}`);
console.log(`scope resolves to ${scope.href}`);
console.log(`start_url inside scope: ${start.href.startsWith(scope.href)}`);
for (const icon of man.icons ?? []) {
const r = await fetch(new URL(icon.src, url));
const b = Buffer.from(await r.arrayBuffer());
const png = b.subarray(1, 4).toString() === 'PNG';
const real = png ? `${b.readUInt32BE(16)}x${b.readUInt32BE(20)}` : `not PNG (${r.headers.get('content-type')})`;
console.log(`icon ${icon.src}: sizes says ${icon.sizes}, bytes say ${real}, ${b.length} B, ${real === icon.sizes ? 'match' : 'MISMATCH'}`);
}
- A driver,
manifest-run.mjs, that opens each page with theopen()launcher printed in How to check if a service worker is registered and callss.cdp.send('Page.getAppManifest').
Steps
- Step 1.
Start the demo site.
node manifest-demo.mjsserving http://localhost:8792/ - Step 2.
Read the manifest off the wire, headers included.
curl -s -D - http://localhost:8792/app.webmanifest | head -14HTTP/1.1 200 OK content-type: application/manifest+json … Content-Length: 446 { "name": "Orders Console", "short_name": "Orders", "start_url": "/dashboard/", "scope": "/app/", "display": "standalone", "theme_color": "#0f766e",application/manifest+jsonis the registered type for this file. - Step 3.
Audit the fields, and measure the icons instead of believing them.
node manifest-audit.mjs http://localhost:8792/manifest http://localhost:8792/app.webmanifest crossorigin=absent status 200 content-type application/manifest+json name: "Orders Console" short_name: "Orders" start_url: "/dashboard/" scope: "/app/" display: "standalone" theme_color: "#0f766e" start_url resolves to http://localhost:8792/dashboard/ scope resolves to http://localhost:8792/app/ start_url inside scope: false icon /icons/logo-192.png: sizes says 192x192, bytes say 192x192, 546 B, match icon /icons/logo-512.png: sizes says 512x512, bytes say 192x192, 546 B, MISMATCHTwo findings in a file that parsed without complaint: the start URL sits outside the scope, and the large icon is the small icon under another name.
- Step 4.
Ask Chrome what it made of the same file, then of the corrected one.
node manifest-run.mjs--- Page.getAppManifest on / { "url": "http://localhost:8792/app.webmanifest", "errors": [ { "message": "property 'scope' ignored. Start url should be within scope of scope URL.", "critical": 0, "line": 0, "column": 0 } ], "parsed": { "scope": "http://localhost:8792/dashboard/" }, … } --- Page.getAppManifest on /fixed/ { "url": "http://localhost:8792/fixed.webmanifest", "errors": [], "parsed": { "scope": "http://localhost:8792/app/" }, … }Chrome resolved the conflict by dropping the declared
scopeand keepingstart_url, so the installed scope became/dashboard/, a path nobody wrote down. The error is not critical, so the manifest is still used. The icon produced no error at all, because Chrome does not open the images while parsing. - Step 5.
Point the same audit at a page whose manifest sits behind the login.
node manifest-audit.mjs http://localhost:8792/private/manifest http://localhost:8792/private/app.webmanifest crossorigin=absent status 200 content-type text/html; charset=utf-8 JSON.parse failed: Unexpected token '<', "<!doctype "... is not valid JSON body starts: <!doctype html><meta charset="utf-8"><title>SignA 200 with a content type of
text/htmlis the tell: the gateway answered with the sign-in page. - Step 6.
Read what Chrome reports for that same page.
node manifest-run.mjs--- Page.getAppManifest on /private/ { "url": "http://localhost:8792/private/app.webmanifest", "errors": [ { "message": "Line: 1, column: 1, Syntax error.", "critical": 1, "line": 1, "column": 1 } ], … "data": null }One critical error naming a line and a column, nothing about HTML or about the login, and
dataempty. Step 5 is what turns that message into a cause. - Step 7.
Read the server log to see which requests carried the session cookie.
cat manifest-server.log21:05:57 GET /fixed/ cookie=session=abc123 21:05:57 GET /fixed.webmanifest cookie=- 21:05:57 GET /icons/logo-192.png cookie=session=abc123 … 21:05:59 GET /creds/ cookie=session=abc123 21:05:59 GET /app.webmanifest cookie=session=abc123 21:05:59 GET /icons/logo-192.png cookie=session=abc123The document and the icons carried the cookie, the manifest did not. On the page that declares
crossorigin="use-credentials"the same manifest request carried it. That single attribute is the difference between a manifest a logged-in browser can read and one it cannot.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| errors: [] from Page.getAppManifest | Chrome parsed every member it understood | Still measure the icons, which Chrome never fetched |
| property 'scope' ignored | The start URL is outside the declared scope | Fix one of the two, then confirm the parsed scope |
| sizes says 512x512, bytes say 192x192 | The declaration is wrong, the file is fine | Regenerate the icon or correct sizes |
| content-type: text/html on the manifest | The response is a login page or an SPA fallback | Serve the manifest unauthenticated, or add crossorigin="use-credentials" |
| Line: 1, column: 1, Syntax error. | The body is not JSON at all | Fetch the URL yourself and read the first line |
| short_name missing | The launcher falls back to name and truncates it | Add a short name of 12 characters or fewer |
Common mistakes
What to check next
- How to check favicon of a website: the manifest icons are one of four icon declarations, and they disagree more often than not.
- How to test offline mode in chrome devtools: the manifest claims installability, the service worker delivers offline, and they fail apart.
- How to check if a service worker is registered: an installable application needs both, and the registration is the half that fails silently.
- How to check viewport meta tag: the other head declaration that decides how the installed window renders.
- How to find broken images on a website: the same fetch-and-measure treatment for the images in the page body.
FAQ
How to validate a web app manifest?
Parse it, resolve start_url and scope as URLs against the manifest location, and fetch every icon to compare its real size with sizes. A schema check misses both of the defects above, because both are valid JSON with the right value types.
How to inspect the web app manifest in devtools?
Open DevTools, Application panel, Manifest pane. It lists the parsed members, the icons it resolved and the error list. The capture in step 4 comes from Page.getAppManifest, the protocol method that pane calls.
Why does my manifest come back as HTML?
The browser requests it without cookies, so an authenticated route answers with the sign-in page. Serve the manifest to unauthenticated requests, or add crossorigin="use-credentials" to the link, which made the request carry the session cookie in step 7.
Does start_url have to be inside scope?
Yes, and Chrome enforces it by dropping the scope. The result is an application whose scope is the start URL's directory, so links outside it open in a browser tab instead of the installed window.
Verified
Verified by Maks Vernynode 22.23.2curl 8.21.0Chrome 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
intermediate7 minpublished updated Maks Verny