karaboga.dev Notes

Headless Chrome won’t go below 500px wide: three fixes

Cihat Karaboğa · · Tooling

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
320500 · 320320 · 320
390500 · 390390 · 390
499500 · 499499 · 499
500500 · 500500 · 500
501501 · 501501 · 501
800800 · 800800 · 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-dom captures at load. Timers scheduled after load never run. With --virtual-time-budget=3000 they do, but CSS transitions don't reliably keep up: across 11 runs, an element fading in over one second mostly still read opacity: 0 at 1500ms. Check that the class was added, not the computed value.
  • A plain --screenshot can catch a fade mid-way. Without a time budget the element came out nearly blank; with --virtual-time-budget=3000 it 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.