Subtopic · Root Causes of JavaScript Test Flakiness

Network Latency & Volatility Handling in E2E Testing

Network unpredictability is one of the most persistent Root Causes of JavaScript Test Flakiness: when a suite talks to a real API, variable response times, packet loss, and connection drops manufacture false negatives that have nothing to do with your code. The cure is to route every external call through a deterministic interceptor and, where timing itself is the subject, to emulate a fixed network profile. This guide details the Playwright and Cypress patterns for simulating, intercepting, and stabilizing network behavior in CI.

12 sections 4 child guides URL: /root-causes-of-javascript-test-flakiness/network-latency-volatility-handling/
Live endpoint versus deterministic intercept A request either hits a volatile live endpoint with variable latency and drops, or is captured by a route interceptor returning a fixed-delay deterministic response. test request ? intercept gate live endpointvariable latency, drops route interceptfixed delay, deterministic
Routing requests through a deterministic interceptor replaces volatile live-endpoint timing with a fixed, reproducible response.

Understanding Network Volatility in CI/CD Pipelines #

CI runners rarely mirror production networking. Latency spikes and intermittent drops expose gaps in Async State Management in E2E Tests, where the UI races a delayed payload, and can mimic DOM Mutation & Rendering Races when the browser asserts before an explicit network state.

Deterministic PRs, stochastic nightly Pull-request validation uses deterministic intercepts for fast feedback; nightly gates use stochastic network injection for fidelity. PR: deterministic interceptsfast, reproducible feedback nightly: stochastic injectionhigh-fidelity chaos gate
Reserve deterministic mocks for PR speed and stochastic chaos for nightly reliability gates.

Inject the profile through the pipeline so test code can select intercept delays by environment:

# .github/workflows/ci.yml — pass a network profile to the resilience suite
env:
  NETWORK_PROFILE: "slow-3g"
  CI_TIMEOUT_MULTIPLIER: "2.0" # widen timeouts under throttling to avoid false CI failures
jobs:
  e2e-resilience:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright test --grep "resilience"
        env:
          PLAYWRIGHT_NETWORK_PROFILE: ${{ env.NETWORK_PROFILE }}

Framework-Specific Interception & Throttling Workflows #

Playwright and Cypress inject latency differently, and the choice shapes how you assert loading states.

Playwright route.fulfill versus Cypress intercept delay Playwright injects latency with a setTimeout inside route.fulfill; Cypress uses the intercept delay option directly. Playwright route.fulfill + setTimeout no built-in delay option CDP for engine-wide throttling Cypress cy.intercept({ delay }) delay + forceNetworkError built in alias-wait to synchronise
Cypress exposes a delay option directly; Playwright injects it in the fulfill handler or via CDP.

Playwright’s route.fulfill() and route.abort() mock or fail responses; latency comes from a setTimeout in the handler since fulfill has no delay option. Detailed patterns live in Playwright Route Mocking Strategies.

// tests/network-resilience.spec.ts — 2s latency then a 500, asserting the error state
import { test, expect } from '@playwright/test';

test('handles delayed API gracefully', async ({ page }) => {
  await page.route('**/api/data', async route => {
    await new Promise(resolve => setTimeout(resolve, 2000)); // fixed delay — deterministic window
    await route.fulfill({ status: 500, contentType: 'application/json',
      body: JSON.stringify({ error: 'Service Unavailable' }) });
  });
  await page.goto('/dashboard');
  await expect(page.getByTestId('error-state')).toBeVisible();
});

Cypress builds delay and forceNetworkError into cy.intercept(), aligning with Cypress Network Interception Patterns.

// cypress/e2e/network-failure.cy.ts — force an error, then assert recovery UI
it('recovers from forced network error', () => {
  cy.intercept('POST', '/api/submit', { forceNetworkError: true }).as('failSubmit');
  cy.visit('/checkout');
  cy.get('#submit-btn').click();
  cy.wait('@failSubmit'); // synchronise on the failure, not a guessed delay
  cy.get('.error-banner').should('be.visible');
});

Cross-Origin & Preflight Mitigation Strategies #

Browser security complicates mocking in CI: OPTIONS preflight requests fail unpredictably when mock headers diverge from server expectations, producing silent net::ERR_FAILED states that slip past assertion layers.

Preflight header parity A mock missing CORS headers fails preflight silently; mirroring the production headers passes it. mock without CORS headerspreflight → net::ERR_FAILEDsilent, bypasses assertions mirror production headersAllow-Origin/Methods/Headerspreflight passes
Mirror the real CORS headers in the mock, or the preflight fails invisibly.

When mocking cross-origin endpoints, define Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers in the intercept, and keep a shared fixture so header parity holds across staging, CI, and local.

Latency Is a Distribution, and Fixed Waits Sample It Once #

Every hard-coded wait encodes a bet: that the operation will finish within the chosen duration on the slowest machine the pipeline will ever schedule. The bet is lost occasionally — which is the definition of flakiness — and the usual response of raising the number trades more dead time on every passing run for a slightly thinner tail that never disappears.

The shape of the distribution is what makes this unwinnable. A request that takes 120 milliseconds at the median can take most of a second at the 99th percentile on a loaded shared runner, because the tail is driven by contention, cold starts and garbage-collection pauses rather than by the request itself. No single number sits comfortably above that tail and below a duration that would make the suite intolerably slow.

Conditions escape the trade entirely. A retrying assertion returns as soon as the state holds, so the common case costs the actual latency rather than the budgeted worst case, and the rare slow case is still covered up to the timeout. A suite with a hundred three-second sleeps spends five minutes per run waiting for nothing; replacing them with conditions typically makes the suite both faster and steadier, which is unusual — most reliability work costs time.

The remaining question is which condition to wait on, and the ranking matters. Element present is the weakest and passes on an empty placeholder. Element visible adds layout but says nothing about content. Content matching — a count, a text value, a settled attribute — is the first condition tied to the data the assertion depends on and should be the default. Network settled plus content matching is the strongest, and is worth the extra line when a test must distinguish “the list is empty because the response said so” from “the list is empty because the response has not arrived”. Handling API Timeouts Without Arbitrary Waits works through the replacements in each framework.

Modelling Failure, Not Just Slowness #

Volatility is not only about responses being late. A connection can be refused, hang indefinitely, deliver a truncated body, or return a perfectly valid response containing the wrong content — four distinct code paths in the application, of which most suites exercise only the first.

The hang is the one worth adding first, because it is the branch that strands users. A route that is intercepted and never fulfilled leaves the request pending forever, and the test then asks a question no other test asks: does the client impose its own deadline? An application without one shows a spinner indefinitely, and the test that discovers this is not flaky — it has found the bug. The cost of the test is a few lines, provided the client timeout is configurable so the suite does not wait out a production-length deadline.

Truncated bodies and wrong content types are cheaper still once interception is in place, and they exercise parsing and validation branches that otherwise run for the first time during an incident on a hotel or conference network. Recovery deserves the same attention: failing the first attempt and letting a retry succeed verifies that the interface returns to a working state without a reload, which is what users actually notice.

Each of these scenarios needs its state declared inside the test — an attempt counter in the route handler, not a module-level variable — or the scenario leaks into the next test and produces exactly the kind of order-dependent failure this section exists to eliminate. Testing Offline and Connection Loss States covers all five cases and the contract each should assert.

Budgeting Headroom Instead of Raising Timeouts #

When a suite starts failing on timeouts in CI, the reflex is to raise the timeout, and it works — once. The number that actually predicts stability is headroom: the median duration of an operation divided by its configured timeout.

Below roughly twenty percent, the suite has comfortable margin and a slow week passes unnoticed. Above fifty percent, the suite is one loaded runner away from red, and each subsequent timeout increase moves the cliff without removing it. Tracking that ratio per project turns a recurring argument into a measurement, and it makes the trend visible: a suite whose headroom is shrinking is degrading even while the pipeline stays green.

Two other budgets belong alongside it. Per-worker compute — the runner’s cores divided by the configured worker count — determines how much of the machine each test actually gets, and over-subscription is the most common cause of “CI-only” timeouts. And the timeout itself should be derived rather than guessed: take the 95th-percentile duration of the operation across a few hundred CI runs and set the timeout at three to four times that, which leaves room for the tail without letting a genuinely hung operation consume minutes.

Treated this way, a timeout change stops being a fix and becomes data. A team that has raised the same timeout three times is being told something about its waits, not about its runners.

Common Pitfalls & Engineering Trade-offs #

Network-mocking anti-patterns and fixes Over-throttling, cache behavior, unclosed intercepts, and setTimeout ordering each map to a fix. throttle every route target critical journeys only ignore browser cache Cache-Control: no-store leave intercepts open reset in before/afterEach setTimeout for ordering framework routing guarantees
Scope throttling, control cache, reset intercepts, and trust routing over raw timers.
Pitfall CI Impact Mitigation
Over-throttling all routes Global timeouts, inflated compute Target only critical journeys and third-party deps
Ignoring browser cache False positives on repeated runs Cache-Control: no-store or clear storage per test
Failing to clear intercepts State leakage between tests cy.intercept() in beforeEach, context.unrouteAll() in Playwright afterEach
Assuming setTimeout guarantees order Non-deterministic parallel runs Rely on framework-native routing, not raw promise chains

Simulating Slow Conditions Deliberately #

Removing latency variance is the right default; removing all latency is a different decision with its own cost. An application tested exclusively against instant responses never exercises the states users spend most of their time in — the loading skeleton, the disabled submit button, the optimistic update that has not been confirmed, the second click that arrives before the first request returns.

Those states are where a surprising share of real defects live. A button that is not disabled during submission produces duplicate orders. A skeleton that never resolves because the error path forgot to clear it produces a permanently loading page. An optimistic update with no rollback leaves the interface claiming a change that the server rejected. None of these fail when the response is instantaneous, because the intermediate state is never observable.

Deliberate throttling makes them observable, and doing it at the runner or route level is far better than adding delays inside handlers: a route-level delay models the network rather than the mock, and it can be scoped to the requests a test cares about instead of slowing everything. Combining a throttled route with the double-click scenario — dispatch the second interaction while the first request is in flight — turns a class of production bug into a deterministic test.

// Delay one route to make the in-flight state observable and assertable.
// Trade-off: each such test costs its delay in wall-clock time; 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));   // in-flight window
  await route.fulfill({ status: 201, json: { id: 'ORD-1' } });
});

await submit.click();
await expect(submit).toBeDisabled();              // no duplicate submissions
await expect(page.getByRole('status')).toHaveText(/submitting/i);

The distinction worth keeping is between modelling latency and suffering it. Suffering real network variance makes a suite non-deterministic; modelling a known delay for a specific request makes an otherwise invisible state testable, at a cost the test states explicitly.

Retry Logic in the Application, Not Just the Suite #

Client-side retries deserve scrutiny in this topic because they interact with test reliability in both directions. An application that retries silently masks intermittent server errors from monitoring exactly as a test retry masks them from CI — and in production nobody re-runs to check. Tests are frequently the only place that behaviour is ever examined.

Two properties decide whether an application retry is safe. Idempotency determines whether repeating the request can duplicate an effect: a read is always safe, a create is not unless the server deduplicates on a key the client supplies. Backoff determines whether the retry helps or amplifies the problem: immediate retries against a struggling service add load at exactly the wrong moment, while exponential backoff with jitter spreads the load and gives the dependency room to recover.

Both are testable with route-level control, and the tests are unusually valuable because the behaviour is otherwise invisible. Counting attempts in the handler and deciding the outcome per attempt lets a spec assert that exactly three attempts were made, that the delays increased, and that a non-idempotent request was not repeated at all. That is a far stronger statement than “the request eventually succeeded”, and it is the assertion that catches a retry policy silently duplicating orders.

The connection back to flakiness is direct: a suite whose tests pass because the client quietly retried is measuring the retry policy rather than the feature, and it will keep passing when the underlying dependency degrades. Retrying Idempotent Requests Without Masking Flakiness covers the idempotency keys and backoff assertions in detail.

One measurement makes the whole topic tractable: record how long each wait actually took, not merely whether it succeeded. A wait that usually resolves in 80 milliseconds and occasionally takes three seconds is describing a bimodal distribution — typically a cache miss, a cold start or a retry inside the client — and that shape is far more informative than the pass rate. Tests whose wait durations are bimodal are the ones that will fail next month, and they are visible in the data long before they fail at all.

Reliability Metrics & KPI Targets #

Network-handling targets Targets for flake rate, simulation coverage, timeout buffer, and retry policy. < 0.5%flake rate 100%API mock coverage timeout buffer 0retries on mocks Fail fast on mocked failures; isolate network from application logic.
Deterministic mocks let you hold a sub-0.5% flake rate with zero retries on mocked failures.
Metric Target Strategy
Flake rate < 0.5% Deterministic intercepts over test-level retries
Network simulation coverage 100% of external deps Route-level mocking in CI, bypassed locally
CI timeout buffer throttled response time Dynamic timeout via pipeline env vars
Retry policy Zero on mocked failures Fail fast; isolate network vs. application logic

FAQ #

How do I differentiate network flakiness from application bugs? Isolate the network with interceptors. If the test passes with mocked stable responses but fails against real endpoints, the cause is network volatility or weak client-side error handling — not your test.

Should I throttle every API call? No. Target critical journeys and third-party dependencies; global throttling inflates CI time and hides real performance regressions.

How do I keep mocked latency deterministic? Use a fixed delay (a constant setTimeout or delay) or an emulated network profile via CDP — see Simulating Slow 3G Conditions in Playwright — so the window has a known floor.

Is it ever right to let a test hit a real API? For one test per integration, yes — someone has to verify that the real contract still holds, and that check belongs on a schedule rather than on the merge path so a vendor outage cannot block unrelated work. Everywhere else the real call is measuring somebody else’s infrastructure, and its latency tail becomes your flakiness. The split is the same one made throughout Network & API Mocking for Reliable Tests: fidelity where the behaviour is the subject, determinism everywhere else.

Our CI timeouts are double the local ones. Is that a problem? It is an acknowledgement that the runner is slower, which is reasonable, and it becomes a problem when it is used as the fix for a marginal wait. Set the CI timeout once from measured headroom rather than by trial and error, and treat every subsequent increase as evidence that a wait condition has degraded. Three increases to the same timeout is a suite telling you about its waits.

How do I test that a loading state appears at all? Delay the response deliberately for that one route. Without a delay the state may never be observable — a fast mock resolves before the first render — so a test asserting the skeleton appears will pass or fail depending on scheduling. A 300-millisecond route delay makes the intermediate state deterministic and costs the test exactly that much time.

Does throttling the whole browser context work as well as delaying one route? It models a slow connection more faithfully and applies to every request, including assets, which makes the test slower and its failures harder to attribute. Context-level throttling suits a small number of specs that verify behaviour on a poor connection; route-level delay suits everything else, because it isolates the variable being tested.

Explore next

Child guides in this section