Browser tests that pass in a visible Chrome window and fail only in headless mode are some of the most frustrating failures in frontend test automation. They often look random at first, because the same spec, the same selector, and the same application code seem to behave differently depending on how the browser is launched. In practice, the issue is usually not random at all. It is a mismatch between what your test assumes about the browser environment and what Chrome headless actually provides.

If you have ever seen the pattern where a test is green locally in normal Chrome, green in a debug run, but red in CI with --headless, you are dealing with one of the classic cases of browser tests fail in headless but pass in real browsers. The root causes are usually tied to rendering differences, timing, fonts, viewport size, GPU behavior, or application code that depends on browser features that are not identical across modes.

This article is a debugging playbook. It is not about hand-waving “flaky tests” as a generic problem. It is about narrowing the failure to a specific layer, proving the mismatch, and changing either the test or the application so the behavior becomes reliable.

What changes when Chrome runs headless?

Chrome headless is still Chrome, but it is not the same execution environment as an interactive browser in every important way. Modern headless Chrome is much closer to normal Chrome than the older headless implementation was, but differences still show up in the areas that matter to tests.

Common mismatch areas

  • Viewport and window metrics, headless defaults are often smaller or different from your local browser window.
  • Font availability, CI containers and minimal images often lack system fonts, which changes layout and text wrapping.
  • GPU and compositing behavior, animation and rendering can differ when hardware acceleration is absent or virtualized.
  • Focus and activation state, some UI interactions depend on real user activation or window focus.
  • Timing, headless runs faster in some places and slower in others, which exposes race conditions.
  • Permission and media APIs, clipboard, notifications, camera, and drag-and-drop can behave differently in automation.
  • Browser flags and sandboxing, CI setups often add flags that alter behavior.

If a test only passes when you watch it run, treat that as a clue, not a coincidence. You are usually seeing a timing or rendering assumption that the test never made explicit.

Start by classifying the failure

Before changing code, identify which class of failure you are looking at. That saves hours of blind retries.

1. Is it a selector problem?

The element may exist, but be hidden, detached, replaced, or moved because of layout shifts. Headless mode can expose this when the timing window is narrower.

2. Is it a rendering problem?

The test may click a target that is visible in one mode but clipped, overlapped, or shifted in another. This is common in responsive layouts and font-dependent content.

3. Is it an interaction problem?

Some clicks, hover actions, and keyboard shortcuts depend on state that differs in headless mode, such as focus or pointer positioning.

4. Is it a synchronization problem?

The UI may still be loading, animating, or hydrating when the test interacts with it. Headless often changes the timing enough to surface the race.

5. Is it an environment problem?

Missing fonts, locale differences, device scale factor, or container restrictions can cause browser behavior to diverge.

A useful discipline is to ask, “What exact assumption does the test make here?” If you cannot answer that in one sentence, the test is probably too implicit.

Reproduce the failure in a controlled way

Your first goal is to reproduce the headless failure outside the full CI pipeline. That helps separate environment issues from application issues.

Run the same test in both modes

For Playwright, keep the same browser channel, same viewport, same user agent if possible, and change only headless mode.

import { test, expect } from '@playwright/test';
test('checkout button is clickable', async ({ page }) => {
  await page.goto('https://example.com/checkout');
  await expect(page.getByRole('button', { name: 'Pay now' })).toBeVisible();
});

Then compare a headless run and a headed run with the same environment variables and the same test data.

For Selenium, the principle is the same, use the same browser version and the same driver version, then toggle headless only.

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options() options.add_argument(“–headless=new”) driver = webdriver.Chrome(options=options) driver.get(“https://example.com”) print(driver.title) driver.quit()

Capture screenshots and DOM snapshots

Do not rely only on logs. Capture the DOM, screenshot, and if necessary the computed style for the failing element. If the layout is wrong, the screenshot usually tells you faster than a stack trace.

In Playwright, a simple trace or screenshot on failure is often enough to reveal clipping, overflow, or animation states.

Compare browser settings

Diff these values between headed and headless runs:

  • window.innerWidth and window.innerHeight
  • devicePixelRatio
  • navigator.userAgent
  • locale and timezone
  • browser version, channel, and flags
  • CSS media queries triggered by size or color scheme

A failing test that assumes a 1440px viewport will often behave differently when headless starts at 1280x720.

Rendering differences that look like test flakiness

Rendering is one of the most common sources of headless browser flakiness. The UI looks “the same enough” to a human, but the test interacts with pixels, hit targets, and DOM bounds.

Fonts change layout more than most teams expect

If your CI image does not contain the same fonts as developer machines, text wraps differently. That changes card heights, button positions, and overflow behavior.

Typical symptoms include:

  • a button moving below the fold
  • a text label wrapping and hiding an adjacent icon
  • a tooltip appearing offset from the trigger
  • a screenshot diff that only appears in CI

Fixes:

  • install the fonts your product actually uses
  • avoid asserting exact pixel positions unless necessary
  • use stable layout containers instead of text-width-sensitive positioning
  • make visual regression baselines environment-specific when fonts differ by design

GPU and compositing can affect visibility

Headless runs often use different compositor paths, especially in containers or virtualized CI environments. That can expose animation timing issues, transform bugs, or overlay races.

If a click fails because an overlay is still present, it may be because the overlay fades out on a CSS transition that is slower in headless mode.

A good diagnostic step is to temporarily disable animations in the test environment and see whether the failure disappears. If it does, the problem is probably not the selector, but the transition.

Viewport assumptions break easily

A test can pass in a real browser where the window is large enough, then fail in headless because the viewport is smaller and the mobile menu or sticky header changes the layout.

Be explicit about the viewport in your test runner.

import { defineConfig } from '@playwright/test';

export default defineConfig({ use: { viewport: { width: 1440, height: 900 } } });

If the application is responsive, test the actual breakpoint that the feature targets. Do not assume your desktop window is the same as CI.

Timing bugs often show up first in headless

Headless mode does not create race conditions, it exposes them. If a test depends on a network request finishing, a component hydrating, or an overlay disappearing, headless can surface the underlying timing issue.

Symptoms of synchronization problems

  • click intercepted by a spinner or toast
  • element found, but not yet attached or stable
  • assertion passes locally and fails in CI
  • test requires arbitrary sleeps to be “reliable”

The fix is usually to wait for a meaningful application state, not a time delay.

typescript

await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Saved')).toBeVisible();

Instead of waitForTimeout, wait for the condition that proves the UI is ready. In Selenium, that means explicit waits, not time.sleep().

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.CSS_SELECTOR, “button.save”)) )

Beware of microtask and animation races

Modern frontend apps update in stages, especially with React, Vue, Angular, or server-side hydration. In headed mode, the render can appear stable by the time the test moves on. In headless mode, the renderer may progress just differently enough for the assertion or click to land too early.

If you see intermittent failures around navigation, modal open state, or list rendering, inspect whether the app exposes a ready signal that the test can observe.

Browser mode assumptions hidden in product code

Sometimes the test is innocent and the application code is the real source of the mismatch.

User activation requirements

Clipboard access, popup opening, file pickers, and autoplay policies can require a real user gesture or a strong simulation of one. Headless can be stricter or simply different in how it handles activation.

If your feature requires a click before calling a privileged API, make sure the test performs the action in the same event flow as a human user would.

Focus-sensitive shortcuts

Keyboard shortcuts often fail when the page or element does not actually have focus. In headed mode you may have clicked the page manually before running the test, which masks the problem. Headless starts from a cleaner state, and the shortcut no longer works.

Hover and pointer state

Menus that open on hover can behave differently when the test synthesizes events in a browser without a visible cursor. Use the framework’s real pointer actions, not DOM event dispatch, for cases where hover state matters.

A practical debugging workflow

Here is a workflow that usually narrows the problem fast.

Step 1, freeze the environment

Make the test runner deterministic before investigating the app.

  • same browser version
  • same viewport
  • same locale and timezone
  • same test data
  • same network mock state
  • same container image

If the failure disappears when you tweak one of these, you found the axis of divergence.

Step 2, run with trace or video

Use the browser automation framework’s debugging artifacts. In Playwright, traces can show you the DOM state at each step. In Cypress, the runner and screenshots can expose the moment the page changed. In Selenium, you may need to add screenshots and DOM dumps manually.

Step 3, inspect the element at the failure point

Check these questions:

  • is the target visible?
  • is it covered by another element?
  • is it enabled?
  • does it have the expected size?
  • is it inside an iframe or shadow root?
  • is it scrolled into view?

Step 4, compare computed styles

If a click or visibility assertion behaves differently, compare the computed styles between headed and headless runs. Differences in display, visibility, opacity, transform, z-index, or overflow often explain the failure quickly.

Step 5, isolate the smallest repro

Strip the test down until only the failing interaction remains. This is especially important for large E2E specs. A smaller repro makes it easier to tell whether the issue is in the test, the app, or the CI container.

Specific causes and how to fix them

Missing fonts in CI

Symptom: text wraps differently, buttons shift, screenshots fail only in CI.

Fix: install the same fonts used locally, or use a stable font package in the container image. If that is not possible, reduce dependence on exact text layout.

Incorrect viewport size

Symptom: different nav layout, mobile menu opens instead of desktop menu, sticky header covers content.

Fix: set viewport explicitly and test the breakpoint you actually support.

Animation not finished

Symptom: click intercepted, unstable screenshot, element moves under the cursor.

Fix: wait for a final state, or disable animation in test mode when animation is not the thing you want to test.

Overlay or toast blocks interaction

Symptom: “element not clickable” or intercepted click errors.

Fix: wait for overlay dismissal, or make the test close it through a real user action if that is part of the flow.

Network timing differs

Symptom: loader appears longer in headless, assertion races the fetch.

Fix: wait for an observable UI event or network completion, not a fixed delay.

Container or sandbox flags change behavior

Symptom: browser starts but permissions, downloads, or rendering are inconsistent.

Fix: review the exact launch flags in CI. Some Chrome flags are necessary in containers, but too many can hide the real bug.

What to log when a headless-only failure happens

When a test fails only in headless mode, collect the evidence that explains the environment, not just the stack trace.

Useful logs include:

  • browser version and channel
  • test framework version
  • launch flags
  • viewport and device scale factor
  • locale and timezone
  • screenshot at the failure step
  • DOM snapshot of the target area
  • network state, if the issue could be timing-related

A concise failure report is much more useful than five retries. It helps you see whether the failure repeats consistently or only under a particular matrix entry.

How to avoid headless-only failures in the first place

Make browser state explicit

Do not rely on defaults if the test needs a specific size, locale, or login state. Declare it.

Prefer user-facing locators

Selectors based on roles, labels, and accessible names tend to survive layout changes better than brittle CSS chains. They also make it easier to tell whether the browser can really see the element.

Wait for meaning, not time

If the app says “Saved”, wait for “Saved”. If a modal should close, wait for the modal to disappear. This is more stable than sleeping for 500 ms and hoping the machine is fast enough.

Treat visual diffs as environment-sensitive

Visual regression testing is valuable, but only if your environment is controlled. A font or rendering mismatch can create noisy diffs that hide real regressions.

Test in both modes when the behavior is fragile

For features involving canvas, drag-and-drop, media, print layouts, or complex animations, it is often worth validating in headless and headed runs. That gives you confidence that the app works under automation and under realistic browser conditions.

When the issue is in the test, not the app

A surprising number of headless failures are test design problems.

Common examples:

  • the test clicks before the element is stable
  • the selector matches the wrong duplicate element
  • the test depends on a side effect from a previous spec
  • a mocked API response does not match production timing
  • the assertion checks a transient state instead of the final one

If the same test is brittle across browsers, it is usually under-specified. If it only breaks in headless, it is probably under-synchronized or over-dependent on rendering details.

A strong test describes the user-visible state it needs, not the internal sequence of browser events it hopes will happen.

A checklist you can reuse during triage

When the next failure appears, walk through this list:

  1. Reproduce it in a local headless run.
  2. Compare browser version, viewport, locale, and timezone.
  3. Capture screenshot, trace, and DOM snapshot.
  4. Check for hidden overlays, animations, or clipping.
  5. Verify fonts are present in the CI image.
  6. Confirm the element is visible, enabled, and stable before interaction.
  7. Replace fixed sleeps with explicit waits.
  8. Review launch flags and container constraints.
  9. Reduce the test to the smallest failing action.
  10. Decide whether the fix belongs in the app, the test, or the environment.

Final thoughts

The phrase “browser tests fail in headless but pass in real browsers” usually points to an environment mismatch, but that mismatch can hide several different problems. Sometimes it is a missing font. Sometimes it is a race between rendering and interaction. Sometimes the app assumes focus, pointer state, or browser metrics that do not exist in CI.

The fastest way to debug Chrome headless debugging issues is to stop treating headless as a mysterious mode and start treating it as a slightly different browser profile with its own rendering and timing characteristics. Once you isolate the difference, the fix is usually straightforward, explicit browser configuration, better waits, more resilient locators, or a small change to the application UI.

If you build your tests around user-visible states, deterministic browser settings, and meaningful synchronization points, headless browser flakiness becomes much less mysterious, and much easier to control.