Baobaobaolin.com
date
entry
027
topic
tooling
rev
—

Headless Chrome at 375px wide lays the page out at 500px

--window-size=375,300 produces a PNG that really is 375×300. But the page saw an innerWidth of 500, and the text on the right was cut off.

I make OG cover images with headless Chrome, and the part of the command that matters is --window-size=2400,1260 --screenshot. Use the same method for a phone layout, set the width to 375, and the PNG really is 375 pixels wide.

But the page in that image was laid out at 500px and the right side cut off. This note is about what --window-size does below a width of 500, plus a separate 87px gap in height. It covers the measurements, why the screenshot has the right size and the wrong content, and what to use instead for narrow layouts.

Measured: every width below 500 becomes 500

The test page has an inline script that writes innerWidth, innerHeight and a few other values into the page while it loads. Each width gets a new profile directory and one headless Chrome launch (Google Chrome 154.0.8037.98, macOS):

"$CHROME" --headless=new --disable-gpu --user-data-dir="$PWD/p$W" \
  --window-size=$W,800 --virtual-time-budget=2000 --dump-dom "file://$PWD/w.html"
--window-size widthinnerWidth
320500
375500
499500
500500
501501
640640

With this version and these settings, from 500 up you get what you asked for, and below 500 you get 500. I also tried --headless=old and got the same 500; but Chrome's headless documentation says that since 132.0.6793.0 the old headless mode is only available as the separate chrome-headless-shell binary, so I probably never ran the old implementation at all.

The screenshot has the right size and the wrong content

The trouble is that the screenshot doesn't give it away. With --screenshot and --window-size=375,300, the PNG is 375×300, exactly as requested. The test page has a flex bar at the top with L on the left and RIGHT-EDGE on the right:

L                                RIG
innerWidth=500
innerHeight=213

Only RIG survives on the right. The page was laid out at 500px and the screenshot cropped the left 375px. For a responsive site, that means none of your max-width: 480px media queries applied. What you get is the left 375px of a wider layout, and it still looks like a normal narrow screenshot.

Why: 500 is a hard-coded floor, and the window is resized again before capture

The 500 is in Chromium's source (chrome/browser/ui/views/frame/layout/browser_view_layout.h at 154.0.8037.98):

// The minimum width for the normal (tabbed or web app) browser window's
// contents area. ...
static constexpr int kMainBrowserContentsMinimumWidth = 500;

The comment says it is the minimum width of a normal browser window's contents area (popups excepted). New headless runs the full browser, so the floor applies there too.

The screenshot path is in components/headless/command_handler/headless_command.js in the same version: --screenshot clips the capture to the width and height you asked for, and before capturing it calls Browser.setContentsSize to resize the contents area to that size. A width of 375 can't go below the floor and stays at 500, while the clip still cuts at 375, so the right side is lost.

Height goes the other way. With a window height of 800, innerHeight read during load is 713; with 300 it is 213; with 630 it is 543. All three are 87 short. I captured a page at --window-size=1200,630 with a red bar at position: fixed; bottom: 0 and a green column at height: 100vh. The PNG is 1200×630, the red bar sits on the bottom edge at 630, and the green column runs from the top down to the red bar. Yet the text on the red bar, written at load, says innerHeight=543. So at load the contents area is 87px shorter than the window, and setContentsSize brings it to 630 just before capture.

That means JS which reads innerHeight once at load and never updates gets 543, not the 630 in the screenshot; whether it recalculates on resize depends on how the page is written. Where the 87 comes from (my guess is space taken by browser UI) I haven't traced in the source; I only know all three measurements on this machine and version were 87.

For narrow layouts, set the viewport through the DevTools Protocol

--window-size sets the initial window size and can't get past that floor. To set the viewport directly, use the Chrome DevTools Protocol method Emulation.setDeviceMetricsOverride, which isn't bound by the window's minimum width. You don't need Puppeteer: Node.js 22.19 on this machine has fetch and WebSocket built in, the whole script is about 30 lines, and the core is these calls:

// after starting chrome with --headless=new --remote-debugging-port=9333 and connecting to the page
await send("Emulation.setDeviceMetricsOverride",
           { width: 375, height: 300, deviceScaleFactor: 1, mobile: true });
await send("Page.navigate", { url });
await sleep(1500);   // my script just waits 1.5 s; for real use, wait for the load event
await send("Runtime.evaluate", { expression: "innerWidth + 'x' + innerHeight" });
const { data } = await send("Page.captureScreenshot", { format: "png" });

Same page, new result:

viewport 375x300
L                      RIGHT-EDGE
innerWidth=375
innerHeight=300

The width is 375, the height isn't 87 short, and RIGHT-EDGE appears in full on the right. Two conditions apply: set the override before loading the page, and note that the test page has <meta name="viewport" content="width=device-width,initial-scale=1">, which mobile: true makes Chrome honour the way a phone would. A page without that meta may behave differently.

How I split it: for images like OG covers, far wider than 500 and laid out with CSS only, I keep using --window-size; one command is the least work. For phone layouts, or pages that read the window size in JS, I use setDeviceMetricsOverride and re-read innerWidth and innerHeight with Runtime.evaluate right before capturing.

If you remember one thing

Check the size the page saw, not the size of the PNG. The 375-pixel image at the top had exactly the right dimensions; the layout inside it was wrong. Reading innerWidth right before capture and comparing it to the PNG's width is the cheapest check I know.

Revision history

  1. First published