Root cause #
Playwright auto-waits for elements, which papers over slowness for positive assertions but does nothing for transient ones. A test that says “the loading skeleton appears, then the table appears” needs the skeleton to exist long enough to be observed. On a developer laptop or a warm CI cache, the mocked or local response returns in single-digit milliseconds, the React render that shows the skeleton and the render that replaces it land in the same animation frame, and expect(skeleton).toBeVisible() loses the race. The assertion fails intermittently because it depends on whether the runner was fast that second.
The naive fix — sprinkling waitForTimeout — is exactly the masking pattern reliability work tries to eliminate. What you want is to slow the network, not the test, and to slow it by a fixed amount so the loading window has a known floor. Playwright exposes two complementary levers. The first is the Chrome DevTools Protocol’s Network.emulateNetworkConditions, reached through context.newCDPSession, which throttles real throughput and adds round-trip latency engine-wide. The second is per-route latency injected in a route.fulfill handler, which delays a single endpoint without touching the rest of the page. Used deterministically, both turn a flaky timing assertion into a stable one.
Step-by-step fix #
1. Apply a slow-3G profile through a CDP session #
context.newCDPSession(page) gives you the raw DevTools Protocol. Send Network.emulateNetworkConditions with the canonical slow-3G numbers to throttle every request the page makes.
// tests/slow-network.spec.ts — engine-wide deterministic throttling
import { test, expect } from '@playwright/test';
test('shows skeleton under slow 3G', async ({ page, context }) => {
const client = await context.newCDPSession(page);
await client.send('Network.enable');
await client.send('Network.emulateNetworkConditions', {
offline: false,
latency: 400, // 400ms RTT — fixed, so the window never collapses
downloadThroughput: (500 * 1024) / 8, // ~500 kbps, classic slow 3G
uploadThroughput: (500 * 1024) / 8,
});
await page.goto('/dashboard');
// The skeleton is now guaranteed to exist long enough to assert.
await expect(page.getByTestId('table-skeleton')).toBeVisible();
await expect(page.getByRole('table')).toBeVisible();
});
2. Throttle a single endpoint with route latency #
When only one request should be slow — say a search call that drives a spinner — inject latency in the route handler instead of throttling the whole page. This keeps the rest of the suite fast.
// Delay exactly one endpoint, leaving page assets at full speed.
await page.route('**/api/search**', async route => {
// Fixed 1.5s delay isolates the spinner assertion to this call only;
// trade-off: adds 1.5s to this test, so scope it to the route that matters.
await new Promise(r => setTimeout(r, 1500));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: [] }),
});
});
This pairs naturally with full Playwright route mocking strategies — mock the body for determinism, then add the delay for the timing window.
3. Centralize the profile as a fixture #
Repeating the CDP boilerplate invites drift. Wrap it in a fixture so every test that needs throttling shares one definition.
// tests/fixtures.ts — one source of truth for the slow-3G profile
import { test as base } from '@playwright/test';
export const test = base.extend<{ slow3g: void }>({
slow3g: [async ({ page, context }, use) => {
const client = await context.newCDPSession(page);
await client.send('Network.enable');
await client.send('Network.emulateNetworkConditions', {
offline: false, latency: 400,
downloadThroughput: (500 * 1024) / 8,
uploadThroughput: (500 * 1024) / 8,
});
await use(); // every test using this fixture gets identical throttling
}, { auto: false }],
});
4. Reset conditions so throttling never leaks #
A CDP session is scoped to its page, but if you reuse a context across tests, restore full speed afterward to avoid slowing unrelated assertions.
// Restore no-throttle so a shared context doesn't bleed latency into later tests.
await client.send('Network.emulateNetworkConditions', {
offline: false, latency: 0,
downloadThroughput: -1, uploadThroughput: -1, // -1 = no throttling
});
Pitfalls #
- CDP is Chromium-only.
newCDPSessiondoes not exist for Firefox or WebKit. Mitigation: gate throttling onbrowserName === 'chromium'and use route-latency injection for the cross-browser path. - Throttling the whole suite. Engine-wide emulation on every test inflates CI wall-clock dramatically. Mitigation: apply it only to tests that assert loading states.
- Mixing real delays with
waitForTimeout. Adding a blind timeout on top of throttling re-introduces nondeterminism. Mitigation: assert on the loading element’s visibility, not on elapsed time. - Forgetting
Network.enable.emulateNetworkConditionssilently no-ops if the Network domain was never enabled. Mitigation: always sendNetwork.enablefirst. - Leaking throttling across a reused context. A context shared between tests keeps the last emulated profile. Mitigation: reset to
-1throughput in teardown or use a fresh context per test.
Reliability targets #
| Metric | Target | How to hit it |
|---|---|---|
| Loading-state assertion flake | < 0.5% |
Fixed latency floor via CDP |
| Latency determinism | ±0ms variance |
Emulated profile, not real network |
| Throttled-test budget | < 10% of suite |
Scope throttling to loading-state tests |
| Cross-browser coverage | Loading races caught in ≥ 2 engines |
Route-latency fallback for non-Chromium |
Frequently Asked Questions #
Q: Why not just use route.fulfill delays for everything instead of CDP?
A: Route delays only affect endpoints you explicitly intercept. CDP emulateNetworkConditions throttles every asset — scripts, images, fonts — which is what you need to reproduce a realistic slow-load and catch races that depend on resource ordering, not just one API call.
Q: Will throttling make my tests flaky in a different way? A: No, the opposite — because the latency is a fixed emulated value, not a real network measurement, the loading window has a guaranteed minimum width. Determinism is the whole point; you are removing variance, not adding it.
Q: Can I run throttled and unthrottled versions of the same test? A: Yes. Make the slow-3G fixture opt-in and parameterize the test, or duplicate the spec with and without the fixture. Reserve the throttled variant for asserting transient states and keep the fast variant for functional coverage.