DEV Community

Cover image for Why Browser Tests Fail Everywhere Except Your Laptop
Markus Gasser
Markus Gasser

Posted on

Why Browser Tests Fail Everywhere Except Your Laptop

A browser test that fails everywhere is usually easy to diagnose.

A browser test that passes on your laptop, passes when you open DevTools, and then fails in CI is much more dangerous. It encourages the team to blame timing, rerun the job, add another sleep, and move on.

That is how flaky suites become permanent infrastructure.

The problem is rarely that CI is “random.” More often, CI is exposing a difference that your local workflow hides: a different build, a different dependency tree, a different network sequence, a different browser lifecycle, or a different rendering path.

Here is how I approach those failures without immediately reaching for longer timeouts.

Start by proving which environment you are actually testing

Preview deployments often look identical to production while behaving differently underneath.

They may use:

  • a different API base URL
  • feature flags tied to branch names
  • temporary authentication callbacks
  • incomplete seed data
  • stricter cookie policies
  • edge caching that has not warmed up
  • environment variables injected by a different build pipeline

Before debugging the test, capture the page URL, build identifier, commit SHA, enabled flags, API host, browser version, and viewport. A screenshot is helpful, but it is not enough. Two pages can look the same while loading different JavaScript bundles or talking to different services.

This guide on debugging browser tests that fail only in preview environments is a useful checklist because it treats the preview environment as its own system rather than a smaller copy of production.

A simple rule helps: if the environment can differ, log the difference before the test starts.

Compare the production build, not just the source code

Developers commonly reproduce a failure by running the application locally in development mode. That can be misleading.

Development builds usually preserve readable function names, include detailed error overlays, skip aggressive minification, and generate source maps differently. CI may be exercising a production bundle where stack traces are compressed, chunks are loaded in a different order, and an exception is swallowed by an error boundary.

This is why a test can appear stable while DevTools is open yet fail in a headless CI run. DevTools changes timing, keeps more diagnostics available, and sometimes makes the failure easier for a human to interpret without changing the underlying cause.

A better reproduction workflow is:

  1. Build the exact artifact produced in CI.
  2. Serve that artifact locally.
  3. Use the same environment variables and feature flags.
  4. Run the same browser version in the same mode.
  5. Preserve console errors and unhandled promise rejections.

The article on tests that pass in DevTools but fail in CI because of source maps, minification, and error stacks goes deeper into this class of failure.

Stop treating live UI updates like normal page loads

Server-sent events, streaming responses, and live notification systems create a different synchronization problem.

The page may be loaded, the button may be visible, and the application may still be waiting for a message that arrives later. A test that asserts immediately after an action is not necessarily flaky; it may simply be observing the wrong state transition.

Avoid waiting for arbitrary delays. Instead, wait for evidence that the application reached the state you care about:

  • a specific notification ID appears
  • a counter changes from one known value to another
  • a row receives a final status
  • a loading marker disappears
  • a network stream produces a recognizable event

When possible, record the event payload or a correlation ID alongside the UI artifact. That gives you a way to distinguish “the server never sent the update” from “the browser received it but the UI did not render it.”

For more patterns, see testing server-sent events, live notifications, and partial UI updates without flaky assertions.

Hydration failures are state mismatches, not selector problems

Modern React applications can render usable HTML before the client-side application has fully taken control. Suspense boundaries, streaming server rendering, and hydration make the page feel faster, but they also create brief periods where the DOM is present without being stable.

A test may find a button and click it while React is replacing that exact node. The resulting error looks like a detached element, an intercepted click, or a selector that suddenly stopped matching.

The wrong response is usually to create a more complicated selector.

The better response is to identify an application-level readiness signal. Examples include:

  • a root element gains a hydrated attribute
  • a skeleton disappears
  • a client-side event handler becomes active
  • a streaming region reaches its completed state
  • the same node remains stable across two observations

This overview of browser testing for React hydration, Suspense, and streaming UI explains why these applications need more than generic “element visible” checks.

Verify the dependency graph used by CI

A lockfile is supposed to make builds reproducible, but teams still end up with differences caused by package manager versions, optional dependencies, platform-specific packages, cached modules, or install commands that do not enforce the lockfile strictly.

When a browser test starts failing after a dependency update, record:

  • the package manager and version
  • the lockfile checksum
  • the runtime version
  • the resolved version of the suspected package
  • whether the dependency cache was restored
  • whether the install modified the lockfile

Do not limit the comparison to direct dependencies. A transitive update in a router, date library, UI component, or polyfill can change browser behavior without appearing in the application code diff.

The checklist in what to log when browser tests fail only in CI after a dependency lockfile change is especially useful when the test failure begins immediately after routine dependency maintenance.

Treat third-party scripts as asynchronous dependencies

Analytics, support widgets, consent managers, fraud tools, payment SDKs, and experimentation platforms can all change the page after your application considers itself ready.

They may inject iframes, move focus, modify the DOM, register global event handlers, delay the main thread, or place overlays above interactive elements. Worse, their behavior can vary by geography, cookie state, account, or time of day.

When one of these scripts is involved, capture more than a screenshot. Log which third-party resources loaded, their response status, load duration, and whether they created new frames or overlays.

This article on what QA teams should log when a browser test fails only after a third-party script loads provides a practical baseline.

Build a failure package, not a failure message

“Element not clickable” is not a diagnosis.

A useful CI failure should preserve enough context for someone to investigate without rerunning the test five times. At minimum, keep:

  • screenshot and video
  • browser console output
  • relevant network failures
  • application build ID
  • browser and operating system versions
  • feature flags
  • dependency lockfile checksum
  • current URL and viewport
  • the last meaningful user action
  • a DOM snapshot or page source around the failure

The goal is not to collect everything forever. The goal is to make the first failure actionable.

Once you compare environments, production bundles, live update states, hydration readiness, dependency resolution, and third-party behavior, many “random CI failures” stop being random. They become ordinary engineering problems with observable causes.

Top comments (0)