---
title: "Testing a website at phone width: why headless screenshots lie"
description: "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."
date: 2026-10-11
lang: en
canonical: https://goingstellar.ro/methods/test-website-phone-width-headless-screenshots/
author: Going Stellar
publisher: GOING STELLAR S.R.L.
topic: Engineering / Web testing
license: CC BY 4.0
alternate_ro: https://goingstellar.ro/ro/metode/testare-site-latime-telefon-capturi-headless/
---

# 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.*

> Engineering · Web testing · 2026-10-11 · https://goingstellar.ro/methods/test-website-phone-width-headless-screenshots/

## Key points

- Headless Chrome clamps the layout viewport to about 500 CSS pixels, so a 390-pixel headless screenshot is a crop of a wider layout, not a phone rendering.
- To test a website at phone width, use device emulation over the Chrome DevTools Protocol, then verify the width with a probe page that prints window.innerWidth.
- Images with loading="lazy" inside an overflow: hidden container that moves them with a transform may never load, because they never enter the viewport.
- In CSS grid, 1fr means minmax(auto, 1fr), so one long unbreakable word can push a column off a phone screen; minmax(0, 1fr) removes that minimum.
- Device emulation does not change the rendering engine; a bug seen only on iOS Safari is found faster by counting CSS values on the live page than by trying to reproduce it.

## 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

**Cite this as:** 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/

```bibtex
@misc{goingstellar2026test,
  title  = {Testing a website at phone width: why headless screenshots lie},
  author = {{Going Stellar}},
  year   = {2026},
  url    = {https://goingstellar.ro/methods/test-website-phone-width-headless-screenshots/},
  note   = {2026-10-11}
}
```

Licence. Free to quote with attribution.

## Also available

- This page as HTML: https://goingstellar.ro/methods/test-website-phone-width-headless-screenshots/
- Same article in Romanian: https://goingstellar.ro/ro/metode/testare-site-latime-telefon-capturi-headless/
