I was building a Playwright suite against a Phoenix LiveView app for the first time. Tests ran fine in isolation. Overnight, the full suite timed out across the board. Every failure was in a navigation.
What Playwright Waits For by Default
When you call page.goto() or trigger a navigation through a click, Playwright waits for the load event before moving on. That assumes a traditional request/response cycle: the browser makes a request, the server returns a full HTML document, the browser fires load when everything is done.
LiveView doesn't do that. Navigation happens over a persistent WebSocket connection. The URL changes, the page updates, but there's no new document and no load event in the way Playwright expects. So Playwright waits, hits the timeout, and the test fails.
The Fix: waitUntil commit
The waitUntil option controls what signal Playwright waits for before continuing. Setting it to 'commit' tells Playwright to return as soon as the server has responded and the navigation is committed, without waiting for a full page load cycle that isn't coming.
The change applies anywhere a navigation happens — goto and waitForURL both take the option:
// goto
await page.goto('/', { waitUntil: 'commit' });
// waitForURL
await page.waitForURL(/\/library\//, { waitUntil: 'commit' });
This was a 12-commit PR touching every navigation across the suite. Not a complicated change per file, but it had to be consistent. One nav without waitUntil: 'commit' is enough to hang the whole overnight run.
The Second Step: waitForLiveView
waitUntil: 'commit' gets you past the hang, but it returns early. LiveView still needs to establish its WebSocket connection and mount the view. Acting on the page before that happens produces flaky results.
The pattern is to follow every navigation with a waitForLiveView helper that waits for the socket to connect before returning control to the test. It was already in the suite — the missing piece was waitUntil: 'commit' on the line before it.
await page.goto('/', { waitUntil: 'commit' });
await waitForLiveView(page);
// or after a click-triggered navigation
await page.waitForURL(/\/library\//, { waitUntil: 'commit' });
await waitForLiveView(page);
There is one exception worth noting. When the navigation is a plain href link transition rather than a LiveView-driven one, waitForURL governs the transition on its own and waitForLiveView isn't needed. A comment in the diff flags exactly that case: // href link — waitForURL governs this transition, not waitForLiveView. Knowing which navigations go through LiveView and which don't matters when you're deciding where to apply the full pattern.
The actual helper in this suite does more than a basic selector wait. The environment has a brief WebSocket drop around the nightly deploy window, so there's a retry path built in:
export async function waitForLiveView(
page: Page,
{ retryWithReload = false }: { retryWithReload?: boolean } = {}
): Promise<void> {
if (!retryWithReload) {
await page.locator(".phx-connected").waitFor();
return;
}
try {
await page.locator(".phx-connected").waitFor({ timeout: 10_000 });
} catch (e) {
if (!(e instanceof errors.TimeoutError)) throw e;
await page.reload({ waitUntil: 'commit' });
await page.locator(".phx-connected").waitFor({ timeout: 10_000 });
}
}
The default path just waits for .phx-connected. Pass retryWithReload: true when the page is in a clean navigable state and a reload is safe — if the socket connection times out, the helper reloads and tries once more. Avoid it inside open modals, filled forms, or any state a reload would destroy.
The Broader Point
Playwright's defaults are reasonable for the apps they were designed around. When you add a new framework to your suite, those defaults are the first thing worth checking. The assumption baked into waitUntil: 'load' is that navigation means a new document. LiveView changes that contract, and the suite breaks until you adjust for it.
The tool doesn't know what kind of app it's running against. You do. The configuration has to reflect that. More on this kind of thing at jeffthoensen.com.
Jeff Thoensen is a Context-Driven QA Engineer focused on automation, API testing, and exploratory testing. Find more at jeffthoensen.com
Top comments (0)