Single-page apps break the assumptions most end-to-end tests are built on. There's no full page load between screens, the DOM you assert against may not be the DOM the user eventually sees, and half the elements you care about live outside the component tree that rendered them. Tests written as if the app were server-rendered HTML end up flaky in ways that are maddening to debug.
This is a tour of the five SPA behaviors that produce the most false failures — hydration, client-side routing, virtualized lists, optimistic UI, and portals — with a Playwright pattern for each. The examples target React, but the failure modes apply to any client-rendered framework.
Hydration: the DOM lies before it's interactive
Server-rendered React (Next.js, Remix, and friends) ships HTML first, then hydrates it with JavaScript. During that window the button is visible, the text is correct, and clicks do nothing — the event handlers aren't attached yet. A test that races the hydration boundary clicks into the void and then fails on the assertion that follows.
The instinct is to add a waitForTimeout. Don't. The delay is variable, so you either flake on slow CI or waste seconds on every run. Instead, wait for a signal that hydration is actually done. Playwright's auto-waiting handles element visibility, but not interactivity, so give it something app-specific to key on.
// Bad: races hydration, fails intermittently
await page.goto('/dashboard');
await page.getByRole('button', { name: 'New project' }).click();
// Better: wait for a hydration marker, then act
await page.goto('/dashboard');
await page.waitForFunction(() => document.documentElement.dataset.hydrated === 'true');
await page.getByRole('button', { name: 'New project' }).click();
If you own the app, set a marker (document.documentElement.dataset.hydrated = 'true') in a top-level useEffect. If you don't, wait for behavior that only exists post-hydration — a controlled input accepting a value, or an element that JS injects. waitForLoadState('networkidle') is a tempting shortcut but unreliable: SPAs open long-lived connections and poll, so the network never truly idles.
Client routing: no navigation event to wait on
Click a link in an SPA and the URL changes, the view swaps, but the browser never navigates. Playwright's waitForNavigation and waitForLoadState were built for real page loads, so they resolve immediately or hang. Assert on the resulting UI instead — the thing the user would look for to know they arrived.
// Bad: there is no navigation to wait for
await page.getByRole('link', { name: 'Settings' }).click();
await page.waitForNavigation(); // resolves against nothing meaningful
// Better: assert the destination rendered
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page).toHaveURL(/\/settings$/);
Web-first assertions like toBeVisible() and toHaveURL() retry automatically until they pass or time out, which is exactly what you want across an async route transition. Two more routing traps worth a guard: deep-linking (loading /settings directly should render the same view as navigating to it — test both paths) and the back button, which in an SPA fires popstate rather than a load and is a routine source of stale-view bugs.
await page.goto('/projects/42');
await page.getByRole('link', { name: 'Edit' }).click();
await expect(page).toHaveURL(/\/projects\/42\/edit$/);
await page.goBack();
await expect(page.getByRole('heading', { name: 'Project 42' })).toBeVisible();
Virtualized lists: the row isn't in the DOM
Long lists rendered with react-window, react-virtual, or TanStack Virtual only mount the handful of rows currently in the viewport. The row you want to click may not exist in the DOM at all, and a naive getByText('Row 500') times out even though the item is really there.
Don't assert on the total count of rendered rows — it reflects the window size, not the dataset. To reach an off-screen item, scroll it into view first. Playwright's scrollIntoViewIfNeeded() works when the element exists; when it doesn't yet, scroll the container until the virtualizer mounts it.
// Scroll the virtualized container until the target row mounts
const list = page.getByTestId('project-list');
const target = page.getByRole('row', { name: /Project 500/ });
await expect(async () => {
await list.evaluate((el) => el.scrollBy(0, 600));
await expect(target).toBeVisible({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await target.getByRole('button', { name: 'Open' }).click();
expect(...).toPass() retries the whole block, so each iteration scrolls a little further and re-checks. For assertions about "how many items exist," go to the data source — an API response or a visible count label — rather than counting mounted DOM nodes, which will always undercount.
Optimistic UI: the value you assert may roll back
Optimistic updates render the expected result immediately, before the server confirms. Add a todo and it appears instantly; if the request fails, it vanishes a moment later. A test that asserts right after the click sees the optimistic state and passes — even when the write never landed. That's a false green, the most dangerous kind of flake because it hides real breakage.
Assert on the confirmed state, not the optimistic one. Wait for the network round-trip, or assert on a signal that only appears after the server responds (a persisted ID, a success toast, a spinner clearing).
// Bad: asserts the optimistic render, misses failed writes
await page.getByRole('button', { name: 'Add todo' }).click();
await expect(page.getByText('Buy milk')).toBeVisible(); // true even if the POST 500s
// Better: wait for the server to confirm
const create = page.waitForResponse(
(r) => r.url().includes('/api/todos') && r.request().method() === 'POST' && r.ok(),
);
await page.getByRole('button', { name: 'Add todo' }).click();
await create;
await expect(page.getByText('Buy milk')).toBeVisible();
The rollback path deserves its own test. Use route interception to force the request to fail and assert the optimistic item disappears and an error surfaces — that's the behavior users actually hit on a bad connection, and it's almost never covered.
await page.route('**/api/todos', (route) => route.fulfill({ status: 500 }));
await page.getByRole('button', { name: 'Add todo' }).click();
await expect(page.getByText('Buy milk')).toBeHidden();
await expect(page.getByRole('alert')).toContainText(/couldn't save/i);
Portals and overlays: the element is outside the tree
Modals, dropdowns, tooltips, and toasts usually render through a React portal into document.body, not inside the component that triggered them. Scoping a query to the trigger's container misses them entirely, and stacked overlays (a dialog over a dropdown) create pointer-interception errors where a click lands on the backdrop instead of the target.
Role-based locators sidestep the DOM-position problem because they find the element wherever it lives. A modal built with the right semantics is reachable by its dialog role regardless of where the portal mounted it.
await page.getByRole('button', { name: 'Delete project' }).click();
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Delete' }).click();
await expect(dialog).toBeHidden();
Scope actions to the overlay (dialog.getByRole(...)) so a matching label elsewhere on the page can't steal the click. And always assert the overlay closed with toBeHidden() before the next step, so you don't interact through a backdrop that's still animating out. If Playwright reports the click was intercepted, that's usually a real z-index or focus-trap bug, not a test problem; its trace viewer shows exactly which element caught the event.
Where generated tests help
The patterns above are the difference between a suite you trust and one you mute. They're also easy to get subtly wrong by hand, over dozens of flows. TestVibe generates Playwright tests from a plain-language description of the behavior, then verifies each one by actually running it in an isolated cloud session before marking it generated. A test that only caught the optimistic render, for instance, wouldn't survive that check. You review the generated .spec.ts file and decide whether to keep, edit, or regenerate it.
SPAs aren't harder to test than server-rendered apps; they're differently hard. Once you stop waiting for navigations that never fire and stop trusting DOM that hasn't hydrated, most of the flakiness disappears. Anchor every assertion to what the user would actually see, and let the framework's retries do the waiting for you.
Want tests that already handle hydration, routing, and optimistic UI? Get early access, or read how AI generation works in the docs.