How to check viewport meta tag
Open DevTools, turn on the device toolbar, choose a 390 px phone, and read innerWidth in the console. A page carrying the viewport meta tag with width=device-width reports 390. A page without it reports 980, the fallback layout viewport, and Chrome scales the whole render down to 0.398 to fit.
Why check this
Run this once per template before staging sign-off, and again after any change to the shared page head: a new layout component, a CMS theme upgrade, a server-rendered wrapper. The tag lives in one line that no test touches, so it goes missing without a failing build.
The defect it prevents is a page that lays out at 980 CSS pixels on a 390 pixel phone and is then shrunk to 40 percent to fit. Nothing overflows, no console error appears, and every media query below 980 pixels stays inactive, so the mobile layout never runs. Body text lands near 6 device pixels tall.
The second half of the check is the opposite defect: a tag that is present and locks zoom. That one stops How to test a page at 200 percent zoom from ever passing.
Prerequisites
- Chrome 120 or later and Node 22.
npm i puppeteer-coreinstalls the driver only. It ships no browser and drives the Chrome already on the machine. SetCHROMEif yours is not at the default Windows path. - The viewport meta element reference on MDN lists every key the
contentattribute accepts. - A server that returns the same document with five different viewport tags, so the tag is the only variable. Save it and run
node viewport-server.mjs 8931. Stop it afterwards by the PID it was started with, never by image name.
// viewport-server.mjs
import { createServer } from 'node:http';
const port = Number(process.argv[2] || 8931);
const metas = {
'/none': '',
'/device': '<meta name="viewport" content="width=device-width, initial-scale=1">',
'/no-user-scalable': '<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">',
'/max-scale-1': '<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">',
'/fixed-960': '<meta name="viewport" content="width=960">',
};
const page = (meta) => `<!doctype html>
<html lang="en"><head><meta charset="utf-8">${meta}<title>Invoice 4417</title>
<style>body{font:16px/1.5 system-ui;margin:0;padding:16px}
h1{font-size:24px;margin:0 0 8px}
.card{border:1px solid #999;padding:12px;max-width:640px}</style></head>
<body><h1>Invoice 4417</h1>
<p>Amount due 128.40 EUR, payable by 2026-09-30.</p>
<div class="card">Line items, taxes and the payment reference are listed here.</div>
</body></html>`;
createServer((req, res) => {
const meta = metas[req.url.split('?')[0]];
res.writeHead(meta === undefined ? 404 : 200, { 'content-type': 'text/html; charset=utf-8' });
res.end(meta === undefined ? 'not found' : page(meta));
}).listen(port, () => console.log('serving on http://127.0.0.1:' + port + '/'));
- Two scripts in the same directory. The first reads the layout viewport, the second probes whether zoom is allowed.
// viewport-metrics.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
console.log('chrome', await browser.version(), ' emulating a 390 x 844 phone');
for (const url of process.argv.slice(2)) {
await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true });
await page.goto(url, { waitUntil: 'networkidle2' });
const m = await page.evaluate(() => ({
meta: document.querySelector('meta[name=viewport]')?.content ?? '(absent)',
innerWidth,
clientWidth: document.documentElement.clientWidth,
scale: +visualViewport.scale.toFixed(3),
}));
console.log(`${url}`);
console.log(` meta ${m.meta}`);
console.log(` innerWidth ${String(m.innerWidth).padStart(4)} clientWidth ${String(m.clientWidth).padStart(4)} visualViewport.scale ${m.scale}`);
}
await browser.close();
// viewport-zoom.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
for (const url of process.argv.slice(2)) {
await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true });
await page.goto(url, { waitUntil: 'networkidle2' });
const meta = await page.evaluate(() => document.querySelector('meta[name=viewport]')?.content ?? '(absent)');
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 });
const reached = await page.evaluate(() => +visualViewport.scale.toFixed(3));
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 });
console.log(`asked for 3.0, settled at ${reached} ${meta}`);
}
await browser.close();
- Every browser figure below is one capture on one machine, Chrome 152.0.7977.76 on 2026-09-11.
Steps
- Step 1.
Read the tag out of the delivered HTML first. This catches a tag that a framework strips at build time and never reaches the browser.
curl -s https://example.com/ | grep -io '<meta name="viewport"[^>]*>'<meta name="viewport" content="width=device-width, initial-scale=1">An empty result is a finding, not a broken command. The same command against
https://httpbin.org/prints nothing, because that document carries onemetaelement and it is the charset. - Step 2.
Measure what the tag does to the layout viewport. The three paths differ only in that one line.
node viewport-metrics.mjs http://127.0.0.1:8931/none http://127.0.0.1:8931/device http://127.0.0.1:8931/fixed-960chrome Chrome/152.0.7977.76 emulating a 390 x 844 phone http://127.0.0.1:8931/none meta (absent) innerWidth 980 clientWidth 980 visualViewport.scale 0.398 http://127.0.0.1:8931/device meta width=device-width, initial-scale=1 innerWidth 390 clientWidth 390 visualViewport.scale 1 http://127.0.0.1:8931/fixed-960 meta width=960 innerWidth 961 clientWidth 960 visualViewport.scale 0.406Without the tag, the layout viewport is 980 CSS pixels on a 390 pixel device, and 390 divided by 980 is the 0.398 scale in the third column. The device is irrelevant to that number: 980 is a constant the browser falls back to.
width=960proves the value is read rather than guessed, since the fallback moves to 960 and the scale to 0.406. - Step 3.
Run the same measurement against two live sites, one with the tag and one without.
node viewport-metrics.mjs https://httpbin.org/ https://example.com/chrome Chrome/152.0.7977.76 emulating a 390 x 844 phone https://httpbin.org/ meta (absent) innerWidth 980 clientWidth 980 visualViewport.scale 0.398 https://example.com/ meta width=device-width, initial-scale=1 innerWidth 390 clientWidth 390 visualViewport.scale 1Two live documents, the same 980 and the same 0.398 as the local page with the tag removed. That is the whole verdict: one number, either the device width or 980.
- Step 4.
Check the two values that break zoom. The script asks the browser for a page scale of 3 and reports where it settled.
node viewport-zoom.mjs http://127.0.0.1:8931/device http://127.0.0.1:8931/no-user-scalable http://127.0.0.1:8931/max-scale-1asked for 3.0, settled at 3 width=device-width, initial-scale=1 asked for 3.0, settled at 1 width=device-width, initial-scale=1, user-scalable=no asked for 3.0, settled at 1 width=device-width, initial-scale=1, maximum-scale=1The first page magnifies to 3. The other two stay at 1.
user-scalable=noandmaximum-scale=1are separate keys with the same effect here, so removing one and leaving the other fixes nothing.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| innerWidth 390 at a 390 px device width | The tag is present and sets the layout viewport to the device | Nothing. Move on to the width sweep. |
| innerWidth 980, scale below 1 | No usable viewport tag. Every media query under 980 px is inactive | Add width=device-width, initial-scale=1 to the document head. |
| meta (absent) but the tag is in the source file | A build step, a proxy or a CMS template dropped it | Compare the curl output in step 1 with the repository template. |
| innerWidth equals a fixed number such as 960 | The tag pins a desktop width on purpose | Confirm that is intended. It disables responsive layout at every device size. |
| Page scale settles at 1 when asked for 3 | user-scalable=no or a maximum-scale below 2 is set | Delete both keys. Zoom is a requirement, not a preference. |
Common mistakes
Thresholds
What to check next
- How to test responsive design: the width sweep, which reports nothing useful until the tag is in place.
- How to find css breakpoints of a website: which media queries exist, and why none of them fire at 980 pixels.
- How to find what causes horizontal scroll on mobile: the next failure once the layout viewport is correct.
- How to test a page at 200 percent zoom: the check that a locked
maximum-scalemakes impossible to pass. - How to check tap target size: targets measured at the real device width, not a shrunken 980.
FAQ
What is a viewport meta element?
A meta element in the document head whose name is viewport. Its content attribute tells the browser how wide to make the layout viewport and what page scale to start at. Without it a mobile browser falls back to a fixed width, 980 CSS pixels in the capture above, and scales the render down.
How do I check the meta name viewport content?
Read it from the delivered HTML with the curl command in step 1, then confirm the effect with innerWidth under mobile emulation. Source and effect are different facts. A tag can be present in the response and still be ignored, for example if a second viewport tag later in the head overrides it.
Which value should the content attribute hold?
width=device-width, initial-scale=1 covers the general case. Add nothing else unless there is a measured reason. user-scalable=no, maximum-scale below 2 and a fixed pixel width each disable something a user needs.
Does the viewport tag matter on desktop?
No. Desktop browsers ignore it, which is why a resized desktop window cannot be used to test it. Turn on device emulation, or the check reports a pass on a page that has no tag at all.
Verified
Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0curl 8.21.0
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