Single-page apps are the #1 source of flaky Playwright tests in 2026. Multiple Stack Overflow analyses put the SPA-specific flake rate at around 30% — meaning 1 in 3 SPA tests fail intermittently in CI for reasons that have nothing to do with the application logic. The fix isn't more retries. It's understanding the five distinct race conditions SPAs introduce and the patterns that prevent each one.
This is a deep-dive companion to my general race-conditions post. That one covers the broad categories. This one drills into the SPA-specific patterns: hydration, lazy loading, route transitions, suspense boundaries, and optimistic UI.
Table of Contents
- Pattern 1: Hydration race (the SSR-to-CSR handoff)
- Pattern 2: Lazy-loaded routes and code splitting
- Pattern 3: Suspense boundaries and async data
- Pattern 4: Optimistic UI updates that lie about state
- Pattern 5: Route transitions and unmount races
- Combining the patterns into one fixture
- FAQs
Pattern 1: Hydration Race (the SSR-to-CSR Handoff)
What goes wrong: Next.js, Remix, or Nuxt renders the page on the server. The browser receives static HTML almost instantly. Your test sees a button, clicks it, and... nothing happens. The button's React event handler hadn't attached yet because hydration was still mid-flight.
Why CI is worse: hydration is CPU-bound. CI runners have weaker CPUs than your laptop. Hydration that takes 80ms locally takes 350ms in CI. The window for the race opens wider.
The fix
Add a hydration flag to your app's root layout:
// app/HydrationFlag.tsx
'use client';
import { useEffect } from 'react';
export function HydrationFlag() {
useEffect(() => {
document.body.setAttribute('data-hydrated', 'true');
}, []);
return null;
}
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<body>
<HydrationFlag />
{children}
</body>
</html>
);
}
Then in your tests:
await page.goto('/dashboard');
await expect(page.locator('body[data-hydrated="true"]')).toBeVisible();
// Now safe to click
If you can't modify the app, wait on a known-interactive element. The first button to be hydrated is usually responsive within 100ms — if it's clickable, the rest of the page is too:
await page.getByRole('button', { name: 'Sign in' }).waitFor();
// Hydration is done by the time this button is interactive
Pattern 2: Lazy-Loaded Routes and Code Splitting
What goes wrong: clicking a navigation link triggers a dynamic import() for the route bundle. The bundle takes 200–500ms to fetch, parse, and execute. Your test asserts on the new page's content immediately and finds nothing because the bundle hasn't loaded.
The fix
Wait on the route's specific request:
// Click and wait for the lazy bundle to load
await Promise.all([
page.waitForResponse(r => /\/_next\/static\/chunks\/pages\/dashboard.*\.js/.test(r.url())),
page.getByRole('link', { name: 'Dashboard' }).click(),
]);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Or, simpler — wait on the URL change AND a stable element:
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.waitForURL(/\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
The URL change fires fast (client-side router); the heading visibility waits until the bundle has loaded and rendered. Combined, they work for most cases.
Pattern 3: Suspense Boundaries and Async Data
What goes wrong: React Suspense lets components "throw" while data loads. The fallback (skeleton, spinner) renders. When data arrives, the real component renders in place. Your test asserts on the real content while the skeleton is still showing.
The wrong fix
// Wrong - skeleton might disappear and reappear
await page.locator('.skeleton').waitFor({ state: 'detached' });
The right fix
Wait for the real content directly. Web-first assertions retry until the suspense resolves:
await page.goto('/users/123');
await expect(page.getByRole('heading', { name: 'Alice Smith' })).toBeVisible();
// Playwright auto-retries until the real heading exists
If your test data isn't deterministic (you don't know the exact name), wait on a stable test ID:
await expect(page.getByTestId('user-card')).toBeVisible();
const name = await page.getByTestId('user-name').textContent();
Pattern 4: Optimistic UI Updates That Lie About State
What goes wrong: user clicks "Like." UI immediately shows the heart filled (optimistic update). API call goes out. API fails. UI reverts. Your test asserts on the heart being filled — passes during the optimistic window, fails after the revert.
Or worse: test passes locally because the API is fast and never fails; test fails in CI because the API timed out and the UI reverted before assertion ran.
The fix
Always wait for the underlying state, not the optimistic UI:
await Promise.all([
page.waitForResponse(r => r.url().endsWith('/api/likes') && r.status() === 200),
page.getByRole('button', { name: 'Like' }).click(),
]);
await expect(page.getByRole('button', { name: 'Like' })).toHaveAttribute('aria-pressed', 'true');
The waitForResponse ensures the API succeeded. The aria-pressed assertion then confirms the UI matches the persisted state.
Pattern 5: Route Transitions and Unmount Races
What goes wrong: user navigates from /users/1 to /users/2. The first user's data is still rendering when the route changes. Half-way through unmount, your test interacts with an element that's about to be removed. locator was detached during action error.
The fix
Wait for the new route to be fully present before asserting:
await page.getByRole('link', { name: 'User 2' }).click();
await page.waitForURL(/\/users\/2/);
// Wait for the new user's data to be visible
await expect(page.getByText('User 2 profile')).toBeVisible();
// Now safe to interact
For pages with heavy unmount logic (analytics fires, cleanup), add a small assertion that the old content is gone before asserting the new content is present:
await page.getByRole('link', { name: 'User 2' }).click();
await expect(page.getByText('User 1 profile')).toBeHidden();
await expect(page.getByText('User 2 profile')).toBeVisible();
Combining the Patterns Into One Fixture
Most of these patterns can be wrapped into a single navigation helper:
// fixtures/navigation.ts
import { Page, expect } from '@playwright/test';
export async function navigateAndWait(
page: Page,
url: string,
expectedHeading: string
) {
await page.goto(url);
await expect(page.locator('body[data-hydrated="true"]')).toBeVisible();
await expect(page.getByRole('heading', { name: expectedHeading })).toBeVisible();
}
export async function clickAndWaitForApi(
page: Page,
buttonName: string,
apiPattern: RegExp
) {
await Promise.all([
page.waitForResponse(r => apiPattern.test(r.url()) && r.status() < 400),
page.getByRole('button', { name: buttonName }).click(),
]);
}
Use them in tests:
test('user can update profile', async ({ page }) => {
await navigateAndWait(page, '/account/profile', 'Profile');
await page.getByRole('textbox', { name: 'Name' }).fill('Alice');
await clickAndWaitForApi(page, 'Save', /\/api\/profile/);
await expect(page.getByRole('alert')).toContainText('Saved');
});
One file. Five race conditions handled. Tests stay short.
FAQs
Why do these races appear in CI but not locally?
CPU and network differences. CI runners are slower; the window for races is wider. Reproduce locally by running tests inside a constrained Docker container (2 CPUs, 4GB RAM).
What about React Server Components?
RSC reduces hydration since less code ships to the client, but client components within an RSC tree still hydrate. The patterns still apply for those islands.
Does Suspense make this better or worse?
Better, because the data-loading state is explicit. The skeleton vs real-content distinction is clearer than the pre-Suspense "sometimes empty, sometimes populated" rendering.
Should I disable lazy loading in tests?
No — that hides bugs that real users hit. Wait for the lazy chunks instead.
What about Vue and Svelte SPAs?
Same patterns, different framework names. Vue's onMounted is the equivalent of React's useEffect(() => {}, []). Svelte's onMount too. The hydration flag pattern works identically.
How do I detect which pattern is causing my flake?
Look at the trace viewer. The exact moment the test fails shows the DOM state. If hydration is incomplete, you'll see no event handlers. If a route transition is mid-flight, you'll see partially-mounted components. Match the symptom to the pattern.
Can I use page.waitForLoadState('networkidle') instead?
For SPAs, no. SPAs often have persistent connections (analytics, web sockets) that prevent networkidle from ever firing. Wait on specific signals.
What about Suspense with streaming SSR?
Same pattern — wait for the actual content, not the skeleton. Streaming SSR sends the skeleton then upgrades it; web-first assertions handle the upgrade transparently.
How do I handle race conditions in the navigation itself (concurrent route changes)?
Don't fire concurrent navigations from a test. If your test needs to test concurrent navigation behavior, that's a unit test of your router, not an E2E test.
Are these patterns specific to Playwright?
The principles apply to all E2E frameworks. The API names differ in Cypress, Selenium, etc.
Wrap-Up
SPA flakiness is solvable, but only with patterns that match the SPA's architecture. Generic "add a timeout" advice fails. Wait on the actual signal — hydration flag, API response, route load, content visibility — and your tests stop flaking.
If your team has an SPA suite that's flaky and you want help auditing it, that's part of framework cleanup engagements. Or book a free call.
Related reading:
Tayyab Akmal
AI & QA Automation Engineer
6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.