How to fix flaky assertions caused by `networkidle` misuse in Playwright?
Playwrightnetworkidle as a waitUntil condition tells Playwright to wait until there are no network connections for at least 500ms. On modern applications with background polling, websocket connections, analytics beacons, or service workers, this state is either never reached or takes an unpredictably long time. Tests built around networkidle are inherently fragile — they pass when the network happens to be quiet and fail when it isn't. Using application-visible readiness signals instead is the standard Playwright recommendation.
Common mistake
test('dashboard loads', async ({ page }) => {
await page.goto('https://app.example.com/dashboard', {
waitUntil: 'networkidle', // Hangs on apps with background polling
});
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Also problematic:
await page.waitForLoadState('networkidle');
// Used as a generic "everything is done" wait — unreliable on most SaaS apps
The fix
Replace networkidle with assertions on user-visible readiness indicators:
import { test, expect } from '@playwright/test';
test('dashboard data loads', async ({ page }) => {
// domcontentloaded or load are stable alternatives for initial navigation
await page.goto('https://app.example.com/dashboard', {
waitUntil: 'domcontentloaded',
});
// Wait for the specific content that indicates readiness
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByTestId('stats-panel')).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId('revenue-chart')).not.toHaveText('Loading...');
});
For pages with a known loading indicator:
test('search results appear', async ({ page }) => {
await page.goto('https://app.example.com/products');
await page.getByRole('searchbox').fill('laptop');
await page.getByRole('button', { name: 'Search' }).click();
// Wait for the spinner to disappear, then check results
await expect(page.getByTestId('search-spinner')).toBeHidden({ timeout: 15000 });
await expect(page.getByRole('listitem')).toHaveCount(10);
});
When you genuinely need to wait for all initial API calls to complete, use waitForResponse on the specific calls that load critical data:
const statsPromise = page.waitForResponse('**/api/dashboard/stats');
await page.goto('https://app.example.com/dashboard');
await statsPromise;
await expect(page.getByTestId('revenue-total')).toBeVisible();
Why it works
Playwright's expect assertions use an internal retry loop that polls the condition every ~100ms until it passes or times out. This means expect(locator).toBeVisible() naturally waits for asynchronous content to appear without relying on network quiescence. Application-visible signals — like a heading appearing, a spinner disappearing, or specific text rendering — directly reflect the state the test cares about. Network quiescence is an indirect proxy for that state that breaks when the app has background activity unrelated to the content being tested.
Tips
- The only legitimate use of networkidle is for tests that specifically need to verify no background network activity is happening — for example, testing that a setting change doesn't trigger unintended API calls.
- domcontentloaded is a faster and more predictable alternative to networkidle for navigation — the DOM is parsed and initial scripts run, which is usually enough for framework-rendered SPAs to begin their render cycle.
- If content loads via background fetch (infinite scroll, live charts, real-time feeds), use expect(locator).toBeVisible({ timeout: 20000 }) with an explicit timeout instead of waiting for network silence.
- Profile your slow tests with Playwright traces — if a test spends 10+ seconds in a networkidle wait, switching to a content-based assertion will likely cut that to under 2 seconds.