Engineering · Web testing
Testing a website at phone width: why headless screenshots lie
Headless Chrome will not lay a page out narrower than about 500 CSS pixels, so a 390-pixel screenshot is a crop of a wider page, not a phone. We test phone widths with device emulation over the Chrome DevTools Protocol and then measure the page instead of looking at it. Six traps on our own site were caught only that way.
Updated
The short answer
A headless browser screenshot at phone width is not a phone rendering. Headless Chrome, meaning Chrome running without a visible window, clamps the layout viewport (the width CSS lays the page out against) to about 500 CSS pixels. Ask for a 390-pixel window and you get a 390-pixel image of a 500-pixel layout. To test a website at phone width, use device emulation over the Chrome DevTools Protocol (CDP), which sets the layout viewport itself, and then measure the page rather than judge the image.
The crop does two kinds of damage. Content cut off at the edge looks exactly like horizontal overflow, so you hunt a bug that does not exist. And every media query below 500 pixels never runs, which is where the real phone problems live.
Step 1: prove the viewport before trusting the image
Load a probe page that writes its own window.innerWidth and the root element's client width into the document as visible text, so the number ends up in the screenshot rather than in a console nobody reads. With a window size of 390, our probe printed 500 for both. The resize tool of a browser-automation server we use hit the same limit: asked for 1440, it returned 500.
The fix is emulation, not a window size. In Puppeteer that is page.emulate({ viewport: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }, userAgent }). Emulation sets the layout width, pixel density, touch and the mobile flag, so the media queries under the clamp finally execute. Re-run the probe after emulating; the number should now match what you asked for.
Step 2: measure, don't look
A screenshot answers whether the pixels look right. Most phone defects are about what is absent: a column pushed off screen, an image that never loaded, content that never became visible. So our harness loads each page at each viewport and reports numbers: horizontal overflow and the element that causes it, page height in screens, touch targets smaller than 44 pixels, images not rendered, JavaScript errors and responses with 4xx status. On our site that is 12 pages at 3 viewports.
Two details make the measurement honest. Compare the document's scroll width with the viewport width with any overflow-hiding rule disabled, because hiding overflow removes the scrollbar, not the overflow. And count images by state, using the complete flag after the page settles, not by eye.
The six traps we measured on our own site
1. The headless floor. The 500-pixel clamp described above. Every other check is meaningless until it is ruled out.
2. Lazy images inside overflow: hidden. A marquee is a track wider than its container, moved sideways with a transform, inside overflow: hidden. Images pushed outside never enter the viewport, so a browser that honours loading="lazy" correctly never loads them. In one strip, 39 of 56 images never loaded and showed as white squares.
3. Scroll-driven animation from opacity 0. An element animated with animation-timeline: view() that starts at opacity: 0 holds that state until its range begins. On a page too short to scroll, the range never begins and the content stays invisible. A scroll reveal now animates transform only. Position may depend on scroll; existence may not.
4. 1fr has a content minimum. In CSS grid, 1fr means minmax(auto, 1fr), and the auto minimum is the longest unbreakable word. An email address pushed a whole footer column off screen at 390 pixels. The fix is minmax(0, 1fr). A body { overflow-x: hidden } rule had been hiding the symptom; we removed it so the next such bug shows.
5. Smooth scroll in headless. With html { scroll-behavior: smooth }, a scripted window.scrollTo did not take effect in headless, so tests that scroll and then measure measured the top of the page. Scroll with behavior: 'instant' in tests.
6. Specificity. .nav a has specificity (0,1,1) and beats .btn-primary at (0,1,0), so the button in the header inherited the link colour. Nothing was broken in the layout; the colour was simply the wrong rule winning.
Emulation is not the phone's engine
Device emulation changes the viewport and the user agent. It does not change the engine that resolves the CSS. On a shop page built with a visual page builder, product titles overflowed onto the badge and the price only on iPhones. Desktop Chrome with mobile emulation, the builder's own editor and a native web view on a Mac all rendered the page correctly, so three environments said it was fine.
The cause was -webkit-line-clamp: 0, an invalid value. Chrome quietly treats it as no clamp; iOS Safari applied it literally, collapsing the text box to one line while the text kept painting across several. What found it was not reproduction but counting on the live page: 42 text blocks had the mobile clamp unset and exactly 6 had 0, the same 6 that were broken. When a defect appears only on a device you do not have, make a histogram of the device-specific CSS values on the live page and look for the rare one.
Render in both themes and both motion regimes
Every visual check should run in every regime the page has: light and dark, reduced motion and normal. Our own rendering global rule that collapsed all animation durations under reduced motion also collapsed the animation that positioned items on a circle, so people who asked for less motion got every item stacked at one point. We saw it only because we rendered both regimes.
Theme contrast fails the same way. In one of our Android apps, colours written directly in code did not follow the dark theme: 77 texts fell below the WCAG AA contrast ratio of 4.5:1 at night, the worst at 1.03:1. The mirror error appeared after the fix: a text colour that inverts between themes, used as a background under fixed white text, gave 1.18:1. The check that catches both computes contrast for every text and background pair in both themes.
Where we were wrong
We knew the lazy-image trap, had written it down, and reintroduced it in a second strip, where it was caught again at 8 of 28 images. Knowing the failure did not prevent it; the check did. The overflow-hiding rule that hid the footer bug was one we had written ourselves. And on the iPhone-only bug we spent time reproducing it in three environments before counting values on the live page, which is what found the cause.
Limits and checklist
The headless clamp is an implementation detail observed in August 2026, not a standard; re-run the probe instead of trusting our number. These checks detect absence: an unloaded image, an unsettled element, a page wider than its viewport. They do not say whether the layout is good, and a check covers only what it measures. Our own latency instrument once timed 7% of a request and reported the whole request as fast. A person holding a real phone still has to look.
Checklist. Prove the viewport with a probe. Emulate the device over CDP; never trust a window size in headless. Measure overflow with overflow hiding off. Count unloaded images inside sideways-scrolling containers. Animate transform, never opacity, on scroll. Use minmax(0, 1fr) where content can be long. Scroll instantly in tests. Render light and dark, reduced motion and normal. For a device-only bug, count CSS values on the live page.
Questions and answers
Why does my headless Chrome screenshot at 390px look cut off?
Headless Chrome will not lay a page out narrower than about 500 CSS pixels. A 390-pixel window produces a 390-pixel image of a 500-pixel layout, so content at the right edge is cropped and looks like horizontal overflow. Use device emulation over the Chrome DevTools Protocol, for example page.emulate in Puppeteer, and confirm the width with a probe that prints window.innerWidth.
How do I emulate a phone correctly in Puppeteer?
Call page.emulate with a viewport that sets width, height, deviceScaleFactor, isMobile and hasTouch, plus a mobile user agent, for example width 390, height 844 and deviceScaleFactor 3. This sets the layout viewport itself, so media queries below 500 pixels run. Then load a probe page that prints window.innerWidth to check the emulation took effect.
Why don't lazy-loaded images appear in my marquee or carousel?
A marquee moves a track wider than its container sideways with a transform, inside overflow: hidden. Images outside the container never enter the viewport, so the browser correctly never triggers loading="lazy". Load those images eagerly, and test by counting images whose complete flag is false inside horizontally scrolling containers after the page settles.
Why is content invisible when I use animation-timeline: view()?
A scroll-driven animation holds its starting state until its range begins. If the element starts at opacity 0 and the page is too short to scroll, the range never begins and the element stays invisible. Animate only transform on scroll, so position depends on scroll but visibility does not, and test both with and without reduced motion.
Does Chrome mobile emulation show iOS Safari bugs?
No. Emulation changes the viewport and user agent but not the engine that resolves CSS. An invalid value such as -webkit-line-clamp: 0 is ignored by Chrome and applied literally by iOS Safari. When a defect appears only on a device you do not have, compare device-specific CSS values across the live page and look for the rare value.
How to cite this
Going Stellar (2026). Testing a website at phone width: why headless screenshots lie. GOING STELLAR S.R.L..
https://goingstellar.ro/methods/test-website-phone-width-headless-screenshots/