UI TESTING8 min read

Why Your React UI Tests Are Flaky (and How to Fix It)

A flaky React test is a timing problem in disguise. This guide covers the nine most common causes, and the exact fix for each, so you can turn a jittery suite into one you can trust.

A flaky test is one that passes and fails on the same code with no changes in between. In React, the culprit is almost always timing: your test checks for something before React has finished rendering or updating. The fix is to stop guessing with fixed delays and start waiting for the actual condition, then clean up the other usual suspects, which are brittle selectors, shared state between tests, and data that changes from run to run.

That is the whole diagnosis in a paragraph. Below, each common cause gets paired with the exact change that stops it, so you can work down the list and watch your pass rate settle.

What makes a test flaky?

Flakiness is non-determinism leaking into a test that is supposed to be deterministic. Same input, same code, and yet the result flips. It erodes trust fast. Once a suite cries wolf a few times, people start rerunning failures out of habit and stop reading them, which defeats the point of having tests at all.

The good news is that flaky React tests come from a short list of causes. Fix these and most of the noise disappears.

Cause 1: You check before React finishes rendering

This is the big one. React updates the DOM asynchronously. When your test queries for an element or asserts on text right after an action, the update may not have landed yet. Sometimes it wins the race and passes. Sometimes it loses and fails.

The fix: wait for the condition, not a moment in time. In React Testing Library, use findBy queries and waitFor instead of getBy right after an async action. In Playwright and Cypress, lean on their auto-waiting assertions, which retry until the element is ready. You are telling the test “proceed when this is true,” which removes the race entirely.

Cause 2: You added a hardcoded wait to make it pass

Everyone does this once. A test is flaky, so you drop in a fixed sleep of half a second and it goes green. The problem is that the delay is either too short on a slow CI machine, where it fails again, or too long everywhere else, where it wastes minutes across the suite.

The fix: delete arbitrary sleeps and replace them with condition-based waiting. If you truly cannot express the condition, wait for the specific network response or the specific element state, never a raw number of milliseconds.

Cause 3: Your selectors are brittle

Tests that target elements by CSS class, deep nth-child paths, or auto-generated class names break the moment the markup shifts. On modern React apps that produce hashed class names at build time, this kind of selector is flaky by design.

The fix: target elements the way a user or an accessibility tree would. Prefer roles and accessible names, and add stable data-testid attributes to the elements you test. These survive refactors and styling changes, which is most of what churns in a frontend.

Cause 4: Tests leak state into each other

A suite where tests pass alone but fail together has an isolation problem. Shared globals, a lingering localStorage value, a mocked module that was not reset, or a database row from a previous test all bleed across boundaries.

The fix: each test should set up its own world and tear it down after. Reset mocks, clear storage, and reset any shared client between tests. If a test depends on another test running first, that dependency is a bug waiting to surface the day the runner changes order.

Cause 5: Your data is non-deterministic

Dates, random values, timezones, and unsorted lists are quiet sources of flakiness. A test that formats today's date passes until it runs at midnight in a different timezone. A list assertion passes until the backend returns the same items in a different order.

The fix: control the inputs. Freeze time with fake timers so “now” is fixed. Seed random generators. Sort collections before asserting on them. Anything that can vary between runs should be pinned down inside the test.

Cause 6: You are hitting a real network

Tests that call a live API inherit every bit of that API's instability: latency spikes, rate limits, transient errors, and data that changes underneath you. Your UI test should be testing your UI, not the reliability of a server.

The fix: mock or stub network calls so responses are fixed and instant. Tools like MSW let you intercept requests at the network layer and return controlled responses. Save the real backend for a small number of dedicated integration tests.

Cause 7: Animations move the target

If a button is still sliding in or fading when your test tries to click it, the click can miss or land on the wrong element. Transitions that look smooth to a user are a moving target to an automated run.

The fix: disable animations and transitions in the test environment, or wait for the element to reach a stable state before interacting. Most teams turn animations off globally during tests with a small bit of CSS.

Cause 8: It passes locally but fails in CI

This one feels like a ghost. The suite is green on your laptop and red in the pipeline. Usually CI runs on a slower, more contended machine, in headless mode, at a different viewport, which exposes races that your fast local machine hid.

The fix: resist the urge to just raise timeouts. Reproduce the CI conditions locally by running headless at the CI viewport, then fix the underlying race with proper waiting. Higher timeouts mask flakiness; they rarely remove it.

Cause 9: State updates happen outside React's control

If your test triggers a state update that React was not expecting, you get warnings about updates not wrapped in act, and behind those warnings are the same timing races that cause failures.

The fix: use the async utilities your testing library provides so updates settle inside React's lifecycle. Interact through the library's user-event helpers rather than firing raw events, and let the framework's waiting mechanisms handle the settling.

The causes and fixes at a glance

CauseThe fix
Checking before render finishesWait for the condition with findBy / waitFor / auto-waiting assertions
Hardcoded sleepsReplace with condition-based waiting
Brittle selectorsUse roles and stable data-testid attributes
State leaking between testsReset and isolate every test
Non-deterministic dataFreeze time, seed randomness, sort collections
Real network callsMock responses at the network layer
Animations moving elementsDisable transitions in the test environment
Passes locally, fails in CIReproduce CI conditions, fix the race, do not just raise timeouts
Updates outside actUse the library's async and user-event utilities

Where record-and-replay tools fit

Record-and-replay tools are not immune to flakiness. They live or die by the same rules: stable selectors, real timing, and a settled DOM. A recorder that anchors to hashed class names will be just as flaky as a hand-written test that does the same.

The ones that hold up make a few of these fixes automatic. Crawly, for example, prefers data-testid and id selectors when it records, so playbacks survive class-name churn, and it types through native input events at a human-like pace rather than dumping values instantly, which avoids the race where a controlled React input rejects a value that arrived too fast. That removes two common causes for you. It does not remove the rest. Disabling animations in your test build and adding stable test ids to your components still pays off for every tool you point at your app, recorders included.

Frequently asked questions

Why does my React test pass sometimes and fail other times?

Almost always a timing race. Your assertion runs before React finishes an async update. Switch from immediate queries to waiting queries that retry until the condition is true.

Are hardcoded waits ever okay?

Rarely, and never as a real fix. A fixed sleep is either too short on slow machines or too slow everywhere else. Wait for a specific condition instead.

How do I stop tests from failing only in CI?

Reproduce the CI setup locally, headless and at the same viewport, then fix the underlying race. Raising timeouts hides the problem rather than solving it.

Do stable test ids really matter that much?

Yes. Selector brittleness is one of the top causes of flaky UI tests. Targeting roles and data-testid attributes instead of CSS classes removes a whole category of failures.

Will a no-code recorder be less flaky than hand-written tests?

Only if it uses stable selectors and real timing. A good recorder automates a couple of the fixes, but app-side habits like stable test ids and disabled animations still matter for reliability.

The short version

Flaky React tests are mostly a timing problem wearing different costumes. Wait for real conditions instead of fixed delays, target elements by role and stable test id instead of CSS, isolate every test, pin down your data, mock the network, and turn off animations in test. Work down that list and a jittery suite turns into one you can trust, which is the only kind worth keeping.