Article · Root Causes of JavaScript Test Flakiness

Headless vs Headed Browser Differences

"It passes when I watch it" is the most misleading sentence in test debugging. Opening a visible browser changes the viewport, the frame cadence, the focus model and often the timing of the very thing that failed — so the headed run is not the same experiment. This guide isolates that axis of CI Environment & Browser Drift: what genuinely differs between headless and headed execution, which differences cause real failures, and how to reproduce a headless-only failure without staring at it.

12 sections URL: /root-causes-of-javascript-test-flakiness/ci-environment-and-browser-drift/headless-vs-headed-browser-differences/
What actually differs between headless and headed runs Viewport size, frame scheduling, focus and window activation, GPU compositing and available fonts differ; the DOM and JavaScript engine do not. differs — investigate here default viewport size requestAnimationFrame cadence window focus & activation GPU compositing, installed fonts identical — look elsewhere JavaScript engine semantics DOM APIs and layout engine network stack and cookies storage and service workers modern headless is the same browser binary — the differences are environmental, not behavioural
Chromium's newer headless mode runs the same engine; the divergence is in what the operating system provides around it.

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.

Observing the failing run instead of a different one Running headed to watch the failure changes viewport, focus and frame timing; a trace records the headless run as it actually failed. rerun with --headedto watch it different viewport, focus and frame cadencepasses — and tells you nothing about the failure trace the headless runretain-on-failure DOM snapshot, network log and action timeline at the failure
The headed rerun is a different experiment; a trace is the same experiment with a recorder attached.

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.
Triage path for a headless-only failure Check viewport first, then animation and focus, then fonts, before concluding it is a timing race. headless-onlyfailure viewport?element in view animation?focus state fonts?then look at timing three cheap environmental checks before the expensive timing investigation
Most headless-only failures resolve in the first two boxes, long before anyone needs to reason about races.

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
Headless parity scorecard Targets for mode agreement, focus dependence, trace capture and viewport pinning. 100%mode agreement 0focus-dependent tests 100%traces on failure 1280×720pinned viewport
Parity is a configuration property: pin the viewport and fonts, and the two modes stop disagreeing.

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.