How to Fix Race Conditions in End-to-End Tests
Race conditions are the number one cause of flaky end-to-end tests. They occur when your test code and the application under test execute asynchronously, and your test assumes the application has finished an operation before it actually has. The test wins the race sometimes and loses it other times -- producing intermittent failures that drive engineers mad.
This guide catalogs the most common race conditions in E2E tests and provides deterministic fixes for each one.
Understanding the Race
In an E2E test, three things happen concurrently:
A race condition occurs when your test asserts something that depends on step 2 or 3 completing, but your test code in step 1 did not wait for that completion.
Test code: [click] -----> [assert text] -----> FAIL
Browser: [click] -----> [send API request] -----> [receive response] -----> [render]
^
Text appears HERE
The test asserts before the text appears because it did not wait for the API round-trip and re-render.
Race Condition 1: Click Then Assert
The most common race condition. The test clicks a button, then immediately checks for the result.
The Problem
// Playwright - FLAKY
await page.click('#add-to-cart');
const count = await page.textContent('#cart-count');
expect(count).toBe('1'); // Cart count hasn't updated yet
// Cypress - FLAKY
cy.get('#add-to-cart').click();
cy.get('#cart-count').should('have.text', '1');
// Actually, Cypress auto-retries assertions, so this specific
// pattern is less flaky in Cypress. But the principle still applies
// to non-retrying checks.
# Selenium - FLAKY
driver.find_element(By.ID, "add-to-cart").click()
count = driver.find_element(By.ID, "cart-count").text
assert count == "1" # Cart count hasn't updated yet
The Fix: Wait for the Expected State
Playwright:await page.click('#add-to-cart');
// Retrying assertion: keeps checking until text matches or timeout
await expect(page.locator('#cart-count')).toHaveText('1');
Cypress:
cy.get('#add-to-cart').click();
// Cypress retries .should() assertions automatically
cy.get('#cart-count').should('have.text', '1');
Selenium:
driver.find_element(By.ID, "add-to-cart").click()
WebDriverWait(driver, 10).until(
EC.text_to_be_present_in_element((By.ID, "cart-count"), "1")
)
The principle: Never read state immediately after an action. Always use a waiting mechanism that polls for the expected state.
Race Condition 2: Navigation Then Assert
After navigating to a new page or route, the test asserts content that has not loaded yet.
The Problem
// Playwright - FLAKY
await page.goto('/dashboard');
const revenue = await page.textContent('#revenue');
expect(revenue).toContain('$');
// The dashboard shell loaded, but the revenue API call is still in flight
The Fix: Wait for Content, Not Navigation
// Playwright - STABLE
await page.goto('/dashboard');
// Wait for the specific data to appear
await expect(page.locator('#revenue')).toContainText('$');
For data that loads via API after the initial page render:
// Wait for the API response, then assert
await page.goto('/dashboard');
await page.waitForResponse(
response => response.url().includes('/api/revenue') && response.status() === 200
);
await expect(page.locator('#revenue')).toContainText('$');
Selenium version:
driver.get("https://app.example.com/dashboard")
Don't just wait for page load -- wait for the data
WebDriverWait(driver, 15).until(
lambda d: "$" in d.find_element(By.ID, "revenue").text
)
Race Condition 3: Form Submit During Validation
The test submits a form while client-side validation is still running.
The Problem
// The form has real-time email validation that calls an API
await page.fill('#email', 'user@example.com');
await page.click('#submit');
// The email validation API hasn't responded yet
// The form either submits with invalid state or the button is still disabled
The Fix: Wait for Validation to Complete
await page.fill('#email', 'user@example.com');
// Wait for the validation indicator to show success
await expect(page.locator('#email-validation-status')).toHaveText('Valid');
// Or wait for the submit button to become enabled
await expect(page.locator('#submit')).toBeEnabled();
await page.click('#submit');
If the application does not expose a visible validation status, wait for the network request:
await page.fill('#email', 'user@example.com');
// Wait for the validation API call to complete
await page.waitForResponse(
resp => resp.url().includes('/api/validate-email')
);
await page.click('#submit');
Race Condition 4: Parallel Test Data Mutations
Two tests running in parallel modify the same data, causing one or both to see unexpected state.
The Problem
# test_a.py - Runs in parallel with test_b.py
def test_update_user_name():
user = api.get_user(id=1)
api.update_user(id=1, name="Alice")
user = api.get_user(id=1)
assert user.name == "Alice" # Might be "Bob" if test_b ran between update and assert
test_b.py
def test_update_user_email():
user = api.get_user(id=1)
api.update_user(id=1, name="Bob", email="bob@test.com")
user = api.get_user(id=1)
assert user.email == "bob@test.com"
The Fix: Unique Test Data per Test
import uuid
def test_update_user_name():
# Create a unique user for this test
test_id = uuid.uuid4().hex[:8]
user_id = api.create_user(name=f"User-{test_id}", email=f"{test_id}@test.com")
api.update_user(id=user_id, name="Alice")
user = api.get_user(id=user_id)
assert user.name == "Alice"
# Cleanup
api.delete_user(id=user_id)
def test_update_user_email():
test_id = uuid.uuid4().hex[:8]
user_id = api.create_user(name=f"User-{test_id}", email=f"{test_id}@test.com")
api.update_user(id=user_id, email="new@test.com")
user = api.get_user(id=user_id)
assert user.email == "new@test.com"
api.delete_user(id=user_id)
The principle: Every test should create its own data and never depend on pre-existing state. This eliminates cross-test interference entirely.
Race Condition 5: Animation Interference
An element is mid-animation when the test tries to interact with it. The click lands on the wrong coordinates because the element is still moving.
The Problem
// A dropdown menu slides open with a CSS transition
await page.click('#menu-button');
// The dropdown is animating open
await page.click('#menu-item-settings'); // Click misses because element is moving
The Fix: Disable Animations in Tests
Playwright:// playwright.config.ts
export default defineConfig({
use: {
reducedMotion: 'reduce',
},
});
Cypress:
Selenium:// cypress/support/commands.js;Cypress.Commands.add('disableAnimations', () => {
cy.document().then(doc => {
const style = doc.createElement('style');
style.textContent =
, ::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
doc.head.appendChild(style);
});
});
// In your test
beforeEach(() => {
cy.disableAnimations();
});
# Inject CSS to disable animations;driver.execute_script("""
const style = document.createElement('style');
style.textContent =
, ::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
document.head.appendChild(style);
""")
Race Condition 6: Toast Notifications and Transient Elements
Your test needs to assert a toast notification that appears briefly and then auto-dismisses.
The Problem
await page.click('#save-button');
// The toast appears for 3 seconds then disappears
// If the test is slow, it might miss the toast entirely
const toast = await page.textContent('.toast-message');
expect(toast).toContain('Saved successfully');
The Fix: Assert Before the Element Disappears
await page.click('#save-button');
// Wait for the toast to appear (with retrying assertion)
await expect(page.locator('.toast-message')).toContainText('Saved successfully');
If the toast disappears too quickly for even retrying assertions, intercept the underlying event:
// Set up a listener before the action
const toastPromise = page.waitForSelector('.toast-message', { state: 'attached' });
await page.click('#save-button');
const toast = await toastPromise;
const text = await toast.textContent();
expect(text).toContain('Saved successfully');
Race Condition 7: Polling and Real-Time Updates
Applications with polling or WebSocket updates create a special type of race condition where the displayed data changes continuously.
The Problem
// Dashboard polls for new data every 5 seconds
await page.goto('/dashboard');
const count = await page.textContent('#active-users');
expect(parseInt(count)).toBeGreaterThan(0);
// The count might be "0" because the first poll hasn't completed,
// or it might have changed between reading and asserting
The Fix: Wait for a Stable State
// Wait for the data to load (non-zero value indicates loaded)
await page.goto('/dashboard');
await expect(page.locator('#active-users')).not.toHaveText('0');
await expect(page.locator('#active-users')).not.toHaveText('Loading...');
// For numeric assertions, use a retrying approach
await expect(async () => {
const text = await page.textContent('#active-users');
expect(parseInt(text || '0')).toBeGreaterThan(0);
}).toPass({ timeout: 10000 });
A Systematic Approach to Finding Race Conditions
When debugging a flaky E2E test, use this process to identify race conditions:
Step 1: Add artificial delay. Insert a 5-secondsleep() before the failing assertion. If the test passes consistently with the delay, you have confirmed a race condition.
Step 2: Identify what the test is waiting for. Is it waiting for an API response? A DOM update? An animation? A network request?
Step 3: Replace the delay with an explicit wait. Use the appropriate wait mechanism for your framework.
Step 4: Remove the artificial delay. The explicit wait should handle the timing.
Step 5: Verify with multiple runs. Run the test 50+ times to confirm stability.
# Run 50 times to verify the fix
for i in $(seq 1 50); do
npx playwright test tests/checkout.spec.ts --reporter=line 2>&1 | tail -1
done
Tracking Race Condition Fixes
After fixing race conditions, track whether the fixes hold over time using DeFlaky. A test that was flaky due to a race condition might become flaky again if the application's performance degrades or the CI environment changes.
# Monitor the test's reliability after fixing
deflaky watch --test "checkout > processes payment" --alert-on-flake
Conclusion
Race conditions in E2E tests follow predictable patterns. The seven patterns in this article cover the vast majority of timing-related flakiness: click-then-assert, navigate-then-assert, form validation timing, parallel data mutations, animation interference, transient element capture, and polling state.
The fix for every race condition follows the same principle: never assume timing, always wait for state. Replace implicit timing assumptions with explicit waits that poll for the condition your test actually depends on.
Use DeFlaky to track which tests are flaky, identify the ones caused by race conditions, and verify that your fixes hold over time. Systematic detection and measurement turn race condition debugging from a frustrating guessing game into a data-driven engineering process.