Playwright's auto-wait is excellent. Click waits for the element to be enabled, visible, and stable. toHaveText retries until the assertion passes. For 95% of UI interactions, you don't need any custom waiting — the built-ins handle it.
The other 5% is where flakiness lives. Apps that talk over WebSockets, poll backend endpoints, run background animations, or fire off async jobs all create cases auto-wait can't see. Those are the cases where senior QA engineers reach for waitForFunction and friends. Here's the toolbox.
Table of Contents
- When auto-wait silently misses a signal
- Pattern 1: waitForFunction for arbitrary state
- Pattern 2: Waiting for a WebSocket message
- Pattern 3: Waiting through a polling endpoint
- Pattern 4: Waiting for animations to complete
- Pattern 5: Waiting for a background job to finish
- Pattern 6: Waiting on a third-party widget loading
- Guidelines: when to write a custom wait vs fix the test
- FAQs
When Auto-Wait Silently Misses a Signal
Auto-wait works on three things: locator visibility, element actionability (clickable, fillable), and assertion conditions. It does not work on:
- JavaScript variables on
window - WebSocket messages
- Polling that updates UI sporadically
- Background timers
- State persisted to localStorage/sessionStorage
- Cookies set by client JS
- Custom DOM events fired by frameworks
If your app's correctness depends on any of these, you need to wait for them explicitly.
Pattern 1: waitForFunction for Arbitrary State
The Swiss army knife. page.waitForFunction runs a JavaScript function in the page context, polls until it returns truthy:
// Wait for a global flag to be set
await page.waitForFunction(() => (window as any).__appReady === true);
// Wait for a localStorage key to be set
await page.waitForFunction(() => localStorage.getItem('userId') !== null);
// Wait for a specific number of items in a state-management store
await page.waitForFunction(() => {
const store = (window as any).__zustandStore;
return store?.getState().notifications.length === 3;
});
Defaults: poll every 100ms, timeout at 30 seconds. Configure both:
await page.waitForFunction(
() => document.title === 'Done',
null,
{ polling: 50, timeout: 5000 }
);
Pattern 2: Waiting for a WebSocket Message
App receives a WebSocket message and updates the UI. You want to wait for the message itself, not just the UI side effect.
test('chat message arrives via websocket', async ({ page }) => {
await page.goto('/chat');
const wsPromise = page.waitForEvent('websocket');
await page.getByRole('button', { name: 'Connect' }).click();
const ws = await wsPromise;
// Wait for a specific message:
const messagePromise = ws.waitForEvent('framereceived', {
predicate: (frame) => frame.payload?.toString().includes('"type":"chat"'),
});
// Trigger the action that should produce the message:
await fetch('http://localhost:3000/api/send-test-message', { method: 'POST' });
const frame = await messagePromise;
const payload = JSON.parse(frame.payload!.toString());
expect(payload.text).toBe('Hello world');
});
Most apps don't need this — you can usually assert on the UI update. Use it when you need to verify the message contract itself, not just the rendering.
Pattern 3: Waiting Through a Polling Endpoint
App polls /api/job-status every 2 seconds until status is "complete." UI updates when it does. Naive test: wait on the UI, hope the timeout is long enough. Better: wait on the actual response with the right status:
await page.goto('/jobs/123');
await page.waitForResponse(
(r) =>
r.url().includes('/api/job-status') &&
r.status() === 200 &&
JSON.parse(/* careful */ '').status === 'complete',
{ timeout: 60_000 }
);
await expect(page.getByRole('alert')).toContainText('Job complete');
The predicate runs against every response. The first one that matches resolves the wait. Set timeout based on realistic upper bound — for jobs, often 60s+.
Pattern 4: Waiting for Animations to Complete
An element fades in over 300ms. toBeVisible() can return true at frame 1 (opacity 0.1) — visibility heuristics differ across browsers.
Two approaches. Best: disable animations for tests entirely. From my race conditions post:
await page.addInitScript(() => {
const style = document.createElement('style');
style.textContent = `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`;
document.head.appendChild(style);
});
Second-best: wait for the animation's specific computed style:
await expect(page.getByRole('dialog')).toHaveCSS('opacity', '1');
toHaveCSS is a web-first assertion. It retries until the value matches.
Pattern 5: Waiting for a Background Job to Finish
UI submits a form. Backend enqueues a Sidekiq/BullMQ job that does the actual work. UI doesn't reflect job completion automatically — user needs to refresh.
The fix: poll the database (or a status endpoint) yourself:
async function waitForOrderProcessed(orderId: string, timeout = 30_000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const r = await fetch(`${API}/orders/${orderId}`);
const order = await r.json();
if (order.status === 'processed') return order;
await new Promise((res) => setTimeout(res, 500));
}
throw new Error(`Order ${orderId} did not process within ${timeout}ms`);
}
// In test:
await submitOrder(page);
const orderId = await page.getByTestId('order-id').textContent();
await waitForOrderProcessed(orderId!);
// Now safe to assert
await page.reload();
await expect(page.getByText('Processed')).toBeVisible();
Pattern 6: Waiting on a Third-Party Widget Loading
Stripe Elements, Intercom messenger, Google Maps — third-party widgets load asynchronously after your page does. Your test interacts before the widget is ready, fails inscrutably.
Wait on the widget's specific ready signal:
// Stripe - wait for their global object and a specific iframe
await page.waitForFunction(() => (window as any).Stripe !== undefined);
await expect(page.frameLocator('iframe[title*="Secure card"]').locator('input')).toBeVisible();
// Intercom - wait for their global function
await page.waitForFunction(() => typeof (window as any).Intercom === 'function');
Each widget exposes something. Read their integration docs. The wait is usually 2–4 lines.
Guidelines: When to Write a Custom Wait vs Fix the Test
Before reaching for waitForFunction, ask:
- Is there a UI signal I can wait on instead? A loading spinner disappearing, an alert appearing, a button becoming enabled. Use a web-first assertion on that signal.
- Is there an API response I can wait on?
page.waitForResponsewith a URL pattern. - Am I waiting because the app is genuinely async, or because my test ordered things wrong? Sometimes the fix is fixing the test, not adding a wait.
Custom waits are a power tool. Reach for them when you've confirmed the built-ins can't see the signal. Otherwise you'll end up with code that's harder to read for no benefit.
FAQs
What's the difference between waitForFunction and waitForSelector?
waitForSelector is deprecated in favor of locator-based waits. waitForFunction remains for arbitrary JavaScript conditions.
Should I use polling intervals or event-based waits?
Event-based when possible (waitForEvent, waitForResponse). Polling (waitForFunction) when the state is on the page and there's no event to subscribe to.
What's a reasonable polling interval?
Default 100ms is fine for most cases. Drop to 25ms for fast-changing state, raise to 500ms for expensive checks.
Can I use these in fixtures?
Yes — wrap them in helper functions. For repeated use, that's the right pattern.
How do I debug a wait that's timing out?
Add a console.log inside the function. The function runs in page context; you'll see what it's seeing on each poll.
Are these patterns safe in parallel test runs?
Yes — each test gets its own browser context. Custom waits don't share state across tests.
What about multi-page coordination (popup waits for opener)?
Use page.opener() to access the original page from a popup, then waitForFunction on cross-window state. Rare but possible.
Can I poll from Node.js side instead of inside the page?
Yes — the background-job pattern shows this. Useful when the state is on the backend, not the frontend.
Should I write a generic "waitForState" helper?
Tempting but usually wrong. Each wait has different semantics. Specific helpers (waitForOrderProcessed) read better than generic ones.
What about Playwright Component Testing?
Same APIs. component.waitForFunction works the same way.
Wrap-Up
Auto-wait covers most cases. The 5% it misses is where flakiness lives. Custom waits — waitForFunction, waitForResponse with predicates, WebSocket waits, third-party readiness checks — fill those gaps. Use them surgically; over-using them makes tests opaque.
If your team has a flaky suite that resists the standard auto-wait fixes, that's exactly the kind of debugging I do in framework 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.