Article · Root Causes of JavaScript Test Flakiness

Simulating Slow 3G Conditions in Playwright

Loading-state races only appear when responses are slow enough for the UI to render a spinner, a skeleton, or a stale-then-fresh swap — on a fast CI runner those windows collapse to zero, the spinner never shows, and a test that asserts it passes today and fails the day the network hiccups. The cure is deterministic throttling: drive Chrome's DevTools Protocol from Network Latency & Volatility Handling to emulate a fixed slow-3G profile so the loading state is always present and always the same length.

12 sections URL: /root-causes-of-javascript-test-flakiness/network-latency-volatility-handling/simulating-slow-3g-conditions-in-playwright/
Fast runner collapses the loading window; throttling restores it A timeline showing that on a fast network the spinner window is too short to assert, while emulated slow 3G widens it into a deterministic, assertable window. Fast CI runner request spinner window too small to assert Emulated slow 3G (deterministic) request stable spinner window (assertable) content Fixed latency + throughput make the loading state a reproducible target.
On a fast runner the spinner window is too short to assert reliably; emulating slow 3G stretches it into a deterministic, reproducible window.

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.

Two throttling levers CDP emulateNetworkConditions throttles the whole engine; route.fulfill delay throttles a single endpoint. CDP emulateNetworkConditionswhole engine, all assets route.fulfill delayone endpoint only
Throttle the whole engine for resource-ordering races, or one route for a single spinner.

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
});
Slow-3G profile numbers The canonical slow-3G profile: 400ms latency and 500 kbps throughput, applied as a fixed floor. latency 400msfixed RTT ~500 kbpsdown/up Network.enablefirst, or no-op
The fixed profile gives the loading window a known floor — enable the Network domain first.

Pitfalls #

  • CDP is Chromium-only. newCDPSession does not exist for Firefox or WebKit. Mitigation: gate throttling on browserName === '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. emulateNetworkConditions silently no-ops if the Network domain was never enabled. Mitigation: always send Network.enable first.
  • Leaking throttling across a reused context. A context shared between tests keeps the last emulated profile. Mitigation: reset to -1 throughput in teardown or use a fresh context per test.
Slow-3G scorecard Targets for loading-state flake, latency determinism, throttled-test budget, and cross-browser coverage. < 0.5%loading flake ±0mslatency variance < 10%throttled tests ≥ 2engines
A fixed emulated profile removes variance entirely — determinism is the point.

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
Reset throttling in teardown A CDP profile set to -1 throughput and 0 latency restores full speed so throttling never leaks into later tests. throttled test teardown resetthroughput -1 later tests full speed
Reset to no-throttle in teardown so a shared context never bleeds latency forward.

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.