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.
What Slow Conditions Make Observable #
Throttling is usually introduced to check that a page is usable on a poor connection, and its more valuable use is exposing intermediate states that a fast mock never produces.
An application answering instantly never shows a loading skeleton, never disables a submit control during submission, never displays an optimistic update awaiting confirmation, and never has to handle a second interaction arriving while the first request is in flight. Those states contain a disproportionate share of real defects: a submit button that stays enabled produces duplicate orders, a skeleton whose error path forgets to clear it produces a permanently loading page, an optimistic update with no rollback leaves the interface asserting something the server rejected.
Deliberate delay makes each of them observable and therefore assertable. The important distinction is between modelling latency and suffering it: a fixed delay applied to one route models a known condition for a specific test, while running against a genuinely slow dependency introduces variance the test cannot control.
// Delay one route so the in-flight state exists long enough to assert on.
// Trade-off: each such test costs its delay in wall-clock time, so keep the
// delay small and the number of these specs bounded.
await page.route('**/api/orders', async (route) => {
await new Promise((r) => setTimeout(r, 400));
await route.fulfill({ status: 201, json: { id: 'ORD-1' } });
});
await submit.click();
await expect(submit).toBeDisabled(); // no duplicate submits
await expect(page.getByRole('status')).toHaveText(/submitting/i);
Throttling Scope and Its Costs #
Where the throttle is applied determines both what it covers and what it costs, and the two options suit different questions.
Context-level throttling slows every request, including assets, fonts and third-party scripts. It is the faithful model of a poor connection and the right choice for a small number of specs asking whether the experience holds up — a first-load test, a critical journey on a constrained profile. Its costs are real: the whole spec runs at the throttled speed, and its failures are harder to attribute because everything is slower at once.
Route-level delay slows one request. It isolates the variable under test, keeps the rest of the spec fast, and produces failures that point at a specific interaction. For the intermediate-state coverage described above, it is almost always the better tool.
Two practical cautions apply to both. A throttled spec needs timeouts scoped for it rather than the global default, or it will fail for reasons unrelated to the behaviour under test. And throttling combined with a fake clock behaves confusingly, because the browser’s network stack does not respect the fake clock — a test that freezes time and then waits for a throttled response can hang indefinitely.