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.

10 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.

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. 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.

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.