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

// 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 + '/'));
// 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();

Steps

  1. 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 one meta element and it is the charset.

  2. 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-960
    
    chrome 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.406

    Without 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=960 proves the value is read rather than guessed, since the fallback moves to 960 and the scale to 0.406.

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

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

  4. 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-1
    
    asked 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=1

    The first page magnifies to 3. The other two stay at 1. user-scalable=no and maximum-scale=1 are 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

Sign: The check is run in a desktop browser window resized to 390 px wide, and the page looks correct.Cause: A desktop window has no viewport meta processing. The layout viewport is the window, tag or no tag, so the missing-tag defect is invisible. The capture above only produced 980 because the script sets isMobile true, which is what turns the mobile viewport pipeline on. Device toolbar in DevTools does the same thing, a narrow window does not.
Sign: The tag is confirmed by grepping the repository template and never in the response.Cause: Frameworks, tag managers and HTML minifiers rewrite the head. Step 1 reads the bytes the browser received. On httpbin.org the delivered document has one meta element and it is the charset, which no source check would have predicted.
Sign: user-scalable=no is removed and the zoom check still fails.Cause: maximum-scale is a separate key and clamps page scale on its own. In the step 4 capture each value alone held the scale at 1 when 3 was requested. Both have to go.
Sign: A missing tag is ranked low because nothing on the page overflows.Cause: The page does not overflow, it shrinks. The document still fits its 980 pixel layout viewport perfectly, so a horizontal-scroll check passes and a screenshot diff against the desktop baseline passes too. The only signal is innerWidth.

Thresholds

980 CSS pixels is the layout viewport Chrome uses when no viewport meta tag applies, independent of device width Source: measured on Chrome 152.0.7977.76 at a 390 x 844 emulated device, steps 2 and 3

What to check next

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.

basic6 minpublished updated Maks Verny