Headless Chrome won’t go below 500px wide: three fixes
While redesigning this site I checked the mobile layout with headless Chrome screenshots at 390px. The right edge looked cut off, and I went looking for a horizontal overflow in my CSS. There was none: headless Chrome had rendered a 500px-wide page and cropped it to 390.
Every number below was measured on 10 October 2026 on macOS 27.0.1 (Apple Silicon) with Chrome for Testing 151.0.7922.34, chrome-headless-shell 151.0.7922.34 and Brave 155.1.97.56.
What the window-size flag actually gives you
A test page printing innerWidth and
clientWidth, captured with
--dump-dom, and the PNG width from
--screenshot:
chrome --headless --window-size=390,844 --dump-dom file:///…/vp.html
chrome --headless --window-size=390,844 --screenshot=out.png file:///…/vp.html
| Requested | Chrome 151 / Brave 155 layout · PNG |
chrome-headless-shell 151 layout · PNG |
|---|---|---|
| 320 | 500 · 320 | 320 · 320 |
| 390 | 500 · 390 | 390 · 390 |
| 499 | 500 · 499 | 499 · 499 |
| 500 | 500 · 500 | 500 · 500 |
| 501 | 501 · 501 | 501 · 501 |
| 800 | 800 · 800 | 800 · 800 |
Below 500, the full browser lays the page out at 500 and then
crops the image to the size you asked for. An element pinned to
right: 0 is simply missing from the 390px PNG. So is
anything your max-width: 400px media query was supposed
to change, because at 500 it never matches.
--headless and --headless=new behave the
same — in current Chrome they're the same mode.
--force-device-scale-factor=1 and
--hide-scrollbars change nothing. Height isn't exact
either: 844 requested gave an innerHeight of 757.
Fix 1: use chrome-headless-shell
The old headless mode now ships as a separate binary,
chrome-headless-shell, and it has no floor: 320 and 390
give real 320 and 390 viewports, with the right media queries
applied, and the height matches the request. If you have
Playwright's browsers installed it's already on disk, next to its
Chromium; otherwise:
npx @puppeteer/browsers install chrome-headless-shell
Fix 2: render the page in a fixed-width iframe
If you're stuck with the full browser, give the page its own viewport. An iframe's document lays out at the iframe's width, no matter how wide the window is:
<iframe src="/" width="390" height="2400" style="border:0"></iframe>
Inside a 390px iframe the page reports
innerWidth 390 and a
max-width: 400px query applies — in both headless
modes of the full browser and in the shell. Serve the host page and the site from the
same origin (a local server, not file://) and the host
can also read measurements out of the frame.
Fix 3: let Playwright set the viewport
Playwright sets the viewport through emulation instead of the window size. Using the same Chrome for Testing build, this gave a true 320px layout:
const ctx = await browser.newContext({ viewport: { width: 320, height: 900 } });
const page = await ctx.newPage();
await page.goto('http://localhost:8080/');
await page.evaluate(() => document.documentElement.clientWidth); // 320
This is how this site’s layout checks run now.
Measure overflow, don't look for it
The screenshot was what fooled me, so the check that replaced it is a number:
const d = document.documentElement;
d.scrollWidth > d.clientWidth; // true means something sticks out
In a 390px iframe, a page with a 600px-wide element reports
scrollWidth 600 against clientWidth 390.
In the screenshot of the same frame, the element is just clipped
at the edge — the overflow is invisible. The opposite of my
original problem, and another reason not to trust the picture.
Two smaller traps
-
--dump-domcaptures atload. Timers scheduled after load never run. With--virtual-time-budget=3000they do, but CSS transitions don't reliably keep up: across 11 runs, an element fading in over one second mostly still readopacity: 0at 1500ms. Check that the class was added, not the computed value. -
A plain
--screenshotcan catch a fade mid-way. Without a time budget the element came out nearly blank; with--virtual-time-budget=3000it was fully drawn in 3 of 3 runs.
One more, specific to this machine: Chrome for Testing hung on both
--dump-dom and --screenshot whenever I
passed a fresh --user-data-dir. Without the flag it was
fine.
The site these checks run against is open source: karabogadev/karaboga.dev on GitHub.