How to Fix Flaky End-to-End Tests
Why Flaky Tests Are Dangerous
A single flaky test does more damage than a missing test. When a test fails randomly, developers learn to dismiss it: "Oh, that test is flaky, just re-run the pipeline." This habit spreads quickly. Once a team normalizes re-running failed pipelines, real failures get buried in the noise. A legitimate regression that breaks the checkout flow looks identical to a flaky test that fails once every ten runs. The team re-runs the pipeline, the flaky test passes, the real failure passes too (because the developer already pushed a second commit that happened to fix it, or because the test happens to pass on retry), and the bug reaches production.
Flaky tests also waste significant CI time and cost. If a 30-minute pipeline fails due to flakiness 20% of the time, teams spend an average of 6 additional minutes per pipeline run waiting for re-runs. Across a team making 50 deployments per week, that adds up to 5 hours of wasted CI compute and developer waiting time every week.
The correct response to a flaky test is to fix it immediately, quarantine it if a fix is not immediately obvious, or delete it if the test does not provide enough value to justify the investigation. Never leave a known-flaky test running in the main pipeline without a plan to resolve it.
Root Cause: Timing Issues
Timing problems are the most frequent cause of E2E test flakiness. The test asserts something about the page before the page has finished updating, and the assertion fails. On the next run, the page happens to update faster, and the test passes. The underlying issue is that the test assumes synchronous behavior in an inherently asynchronous system.
Fixed-Duration Sleeps
The most obvious timing antipattern is using a fixed sleep command like await page.waitForTimeout(2000) or cy.wait(3000). A 2-second wait might be enough on a developer's fast machine but insufficient on a slower CI runner. It is also wasteful: if the operation completes in 200 milliseconds, the test spends 1800 milliseconds doing nothing. Replace every fixed sleep with a condition-based wait.
In Playwright, use auto-waiting (which is the default for most interactions) or explicit conditions like await page.waitForSelector('.result-list') or await expect(page.getByText('Success')).toBeVisible(). In Cypress, chain assertions that retry automatically: cy.get('.result-list').should('be.visible').
Network Response Timing
Tests that assert page content after a form submission often fail because the API response has not arrived yet. The test clicks "Submit," then immediately checks for a success message that depends on the API response. The fix is to wait for the specific network response before asserting.
In Playwright, use await page.waitForResponse(url => url.includes('/api/submit')) after clicking the button. In Cypress, intercept the request with cy.intercept('POST', '/api/submit').as('submit') and then cy.wait('@submit') before making assertions. Both approaches proceed as soon as the response arrives, making the test both faster and more reliable than a fixed sleep.
Animation and Transition Timing
CSS animations and transitions can cause elements to be present in the DOM but not yet in their final position or opacity. A click event on a moving element might miss, or a visibility check might fail because the element's opacity is still transitioning from 0 to 1. The simplest fix is to disable animations in the test environment by injecting a CSS rule like *, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; } at the start of each test. This eliminates animation-related timing issues without affecting functionality.
Root Cause: Shared State
When tests share state, the outcome of one test depends on the outcome of a previous test. If Test A creates a user account and Test B logs into that account, then Test B fails whenever Test A is skipped, fails, or runs on a different parallel worker. Shared state is especially insidious because the tests might pass reliably when run sequentially in a specific order but fail when run in parallel or in a different order.
Database State
The most common form of shared state is the database. If tests create data without cleaning it up, subsequent tests may encounter unexpected records. A product listing test might find 11 items instead of the expected 10 because a previous test created a product and did not delete it.
The fix is to isolate each test's database state. Options include resetting the database to a known state before each test (using a seed script or transaction rollback), using unique identifiers for test data so that parallel tests do not collide, or running each test in a database transaction that rolls back after the test completes. The transaction approach is the fastest because it avoids full database resets, but it requires the test framework and database driver to support it.
Browser State
Cookies, localStorage, sessionStorage, and service worker caches persist between tests if not explicitly cleared. A test that expects a clean login page might find a pre-authenticated session from the previous test. Most frameworks provide hooks to clear browser state before each test. In Playwright, each test gets a fresh browser context by default, which isolates cookies and storage automatically. In Cypress, add cy.clearCookies() and cy.clearLocalStorage() to the beforeEach hook.
Parallel Execution Conflicts
Tests that modify shared resources (like a single admin user account or a global configuration setting) conflict when run in parallel. If two tests both try to update the admin email address simultaneously, one of them will see unexpected data. The fix is to make each test use its own isolated data or to serialize tests that must modify shared resources. Tag conflicting tests with a serial execution flag and let the rest of the suite run in parallel.
Root Cause: Environment Instability
E2E tests depend on a running application, a database, and potentially external services. Any instability in this environment causes test failures that have nothing to do with the application code.
Slow or Unavailable Services
Tests that depend on third-party APIs (payment gateways, email services, geolocation providers) fail whenever those services are slow or unavailable. The fix is to mock external services in the test environment. Use the framework's network interception to return canned responses for third-party API calls. This makes tests faster, deterministic, and independent of external service availability.
Resource Contention in CI
CI runners are often shared infrastructure with variable performance. A test that passes on a fast runner might timeout on a slow one because page rendering takes longer under CPU contention. Address this by setting generous timeouts in CI (longer than local development timeouts), using dedicated CI runners with consistent hardware specifications, and avoiding timeout values that are barely sufficient on fast machines.
Port Conflicts and Startup Races
Tests that start the application server as part of the test setup sometimes fail because the server is not ready when the first test runs. Use health check endpoints to verify that the server is accepting connections before starting the test suite. In Docker Compose, use the healthcheck directive. In scripts, poll the health endpoint in a loop with a reasonable timeout.
Systematic Approach to Fixing Flakiness
When you encounter a flaky test, follow this diagnostic process. First, run the test 50 times in isolation to confirm it is genuinely flaky and to estimate the failure rate. Second, read the failure messages and screenshots from the failed runs, looking for patterns (does it always fail on the same assertion? does it fail more often on a specific browser?). Third, categorize the root cause as timing, state, or environment. Fourth, apply the targeted fix described above. Fifth, run the test 50 times again to confirm the fix eliminated the flakiness.
For suites with many flaky tests, prioritize by impact. Fix flaky tests that block the main pipeline first, then address tests in secondary suites. Track flakiness metrics over time: the number of flaky tests, the average failure rate, and the CI re-run rate. These metrics make the cost of flakiness visible to the team and justify the investment in fixing it.
Flaky E2E tests are caused by timing assumptions, shared state between tests, and environment instability. Fix timing issues by replacing fixed sleeps with condition-based waits, eliminate shared state by isolating test data, and stabilize environments by mocking external services and using health checks. Treat every flaky test as a high-priority bug.