Root cause #
Headless Chromium is no longer a separate implementation — since the new headless mode it is the same binary with no visible window. What changes is everything the windowing system used to supply. The default viewport is set by the automation tool rather than by a maximised window, so a headed run at 1920×1080 shows a table row that a headless run at 1280×720 has scrolled out of view, and the click that “worked” was against an element the headless run genuinely cannot reach.
Frame scheduling is the second mechanism. A backgrounded or window-less page can have requestAnimationFrame throttled or driven by a different scheduler, which changes how quickly CSS transitions settle and how soon an animated element reaches its final position. A test that clicks a button mid-transition hits a moving target in one mode and a settled one in the other. Focus behaves similarly: without a window manager there is no activation, so document.hasFocus() can be false and components that render only while focused — autocomplete panels, focus-trapped modals — behave differently.
The third mechanism is font availability, which is really an image problem wearing a headless disguise. CI containers ship a minimal font set, so text metrics differ, elements wrap differently, and anything asserting position or a screenshot diff fails. None of these are timing bugs, but all of them present as intermittent ones because they interact with whatever else the test is racing.
Step-by-step fix #
1. Make the viewport explicit and identical in both modes #
The single highest-yield change: stop inheriting the window size.
// playwright.config.ts
// Trade-off: a fixed small viewport is fast and reproducible but hides
// wide-screen layout bugs — add one project at a desktop width if that matters.
export default defineConfig({
use: { viewport: { width: 1280, height: 720 } },
});
// cypress.config.js — the same idea for the Cypress runner
module.exports = defineConfig({
e2e: { viewportWidth: 1280, viewportHeight: 720 },
});
With the viewport pinned, --headed and headless disagree about far less, and an off-screen element becomes a real, reproducible failure rather than an artefact of the window someone happened to have open.
2. Reproduce headless-only failures with a trace, not with your eyes #
Watching the run changes it. Capture a trace or video from the headless run instead and inspect it afterwards — the evidence is from the failing experiment rather than from a different one.
# Trade-off: traces cost a few MB per failure and some runtime, which is a
# fraction of the cost of the debugging session they replace.
npx playwright test --trace=retain-on-failure --video=retain-on-failure
npx playwright show-trace test-results/*/trace.zip
A trace gives the DOM snapshot, the network log and the exact action timeline at the moment of failure — including whether the element was out of view, covered or still animating.
3. Remove animation and focus from the equation #
Transitions and focus effects are where the frame-cadence difference actually bites. Disable animation globally for functional runs so a click never lands on a moving element.
// Trade-off: disabling animation removes a whole class of flakiness and also
// removes any chance of catching an animation bug — keep one project animated.
await page.addStyleTag({
content: `*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}`,
});
For focus-dependent components, assert the focus state rather than assuming the window has it, and prefer keyboard-driven interaction over clicks that depend on activation. The wider treatment of settling and transitions is in Timer & Animation Flakiness.
4. Install the fonts the layout assumes #
If the container has no font matching the CSS stack, the browser substitutes one with different metrics. Text wraps differently, elements shift, and position-sensitive assertions and screenshots diverge.
# Trade-off: font packages add roughly 40–120 MB to the image; the alternative
# is layout that differs from production on every text-heavy page.
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-liberation fonts-noto-color-emoji fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
5. Scroll and hit-test explicitly instead of relying on window size #
A click that fails in CI is often a click on an element that is genuinely not reachable at 1280×720 — behind a sticky footer, below the fold, or inside a scroll container the automation never scrolled. Modern runners scroll the target into view automatically, but they scroll the page, and an element inside an independently scrolling panel needs that panel moved.
// Trade-off: an explicit scroll is one more line per interaction and removes a
// whole class of viewport-dependent failures in dense, scrollable layouts.
const row = page.getByRole('row', { name: 'Invoice 4821' });
await row.scrollIntoViewIfNeeded();
await expect(row).toBeInViewport(); // fails with a clear message, not a timeout
await row.getByRole('button', { name: 'Open' }).click();
Asserting visibility before acting converts a confusing “element is not stable” timeout into a statement about what was actually on screen, which is the information you need to decide whether the viewport or the timing is at fault.
6. Run one headed project deliberately, if at all #
Some teams keep a small headed project for smoke coverage, on the theory that it is closer to what a user experiences. That is defensible for a handful of tests and counterproductive as a strategy: a headed run in CI needs a display server, is slower, and cannot be reproduced by anyone whose window manager differs. If you keep one, scope it by tag, run it after the headless suite rather than instead of it, and treat a failure there as a report about the environment rather than about the application.
// Trade-off: a headed project adds infrastructure and CI minutes for a small
// amount of extra realism; scope it tightly or skip it entirely.
projects: [
{ name: 'headless', use: { headless: true } },
{ name: 'headed-smoke', use: { headless: false }, grep: /@smoke/ },
],
Pitfalls #
- Debugging by rerunning headed. The mode that passes is not the mode that failed. Mitigation: capture a trace from the headless run.
- Leaving the viewport unset. Headed inherits the window, headless takes a default; the two disagree about what is on screen. Mitigation: pin the viewport in config.
- Assuming an off-screen element is a timing bug. It is often simply outside a smaller viewport. Mitigation: scroll into view explicitly, or assert against a list rather than a pixel position.
- Depending on window focus. There is no window manager in CI. Mitigation: assert focus explicitly and drive focus-dependent flows from the keyboard.
- Comparing screenshots across images. Different fonts and GPU compositing produce sub-pixel differences. Mitigation: install the same fonts, and set a tolerance rather than requiring an exact match.
- Using the old headless flag for new work. The legacy mode was a different implementation with its own quirks. Mitigation: use the current headless mode that ships with the pinned browser version.
Reliability targets #
| Metric | Target | Notes |
|---|---|---|
| Headless-versus-headed result agreement | 100% | Same viewport, same fonts, animation disabled |
| Tests depending on window focus | 0 | Focus asserted, not assumed |
| Trace capture on failure | 100% | retain-on-failure in CI |
| Screenshot diff tolerance | ≤ 0.2% pixels | Fonts pinned in the image |
| Viewport set explicitly | 100% of projects | No inherited window size |
Frequently Asked Questions #
Q: My test only fails headless. Does that mean headless is broken? A: Almost never. It means the headless environment exposes something the headed one hid — usually a smaller viewport, an element still animating, or a missing font. Capture a trace from the failing run and check whether the element was in view and settled at the moment of the action.
Q: Should CI run headed under a virtual display instead? A: It is possible with a virtual framebuffer, and it mostly buys window activation. The cost is an extra moving part in the image and slower runs. Pinning the viewport, disabling animation and asserting focus explicitly solves the same problems without the display server.
Q: Slowing the run down makes it pass. Does that prove it is a timing bug? A: No — it proves the test is sensitive to timing, which a viewport or animation problem also produces. An element sliding into place under a transition arrives eventually, so a slower run finds it settled. Check the trace for whether the element was moving or off screen at the moment of the action before concluding the cause is a race; the distinction decides whether the fix is a better wait or a pinned viewport.
Q: Does headless change how downloads and dialogs behave? A: It can. Without a window there is no native dialog and no download shelf, so behaviour depends on the automation tool’s handling rather than the operating system’s. Register the download or dialog handler before triggering the action in both modes; a test that works headed because a human dismissed a prompt is not a test.
Q: Why do my screenshot comparisons fail only in CI? A: Font substitution and GPU compositing. The container lacks the font in your CSS stack, so text metrics shift and every subsequent element moves a fraction. Install the fonts in the image, render with a consistent scale factor, and allow a small pixel tolerance.