Browser tests that pass on a fast laptop and fail in CI, or only fail for users on a poor connection, usually have the same root problem, they assume timing that the browser and network do not guarantee. A test may wait for a DOM node, click a button, and expect the next page to be ready, but if an API call is still in flight, a CSS bundle is delayed, or an animation blocks the real click target, the test can race ahead of the app.

When people say their browser tests fail on slow networks, they are often describing a family of issues, not a single bug. Some failures are caused by request timing, some by race conditions between rendering and interaction, some by timeouts that are too aggressive, and some by test code that accidentally depends on cached assets or local development speeds. The trick is not to guess from CI logs, it is to recreate the failure under constrained bandwidth and inspect the exact sequence of browser events.

Why slow networks expose bugs that fast environments hide

A fast network compresses time. That sounds helpful until it hides missing synchronization. If the page reaches its stable state in 300 ms on a local machine, a flaky E2E test may seem reliable even though it is really depending on incidental speed. Under a slower network, the same test has enough time to reveal that the UI is not actually ready when the test thinks it is.

Common failure patterns include:

  • A button becomes visible before its handler is wired up.
  • An API response arrives after the test has already asserted on an empty table.
  • A spinner disappears because the DOM changed, but the underlying request is still pending.
  • A late-loaded JavaScript chunk changes the layout after the test clicks.
  • A retry hides the first failure, but the second attempt hits a different page state.

These are not really “network issues” in the narrow sense. The network is just making the timing window wide enough to expose the bug.

If a test only passes when the app is fast, the test is probably observing the wrong readiness signal.

Start by classifying the failure

Before changing waits or increasing timeouts, classify what actually failed. That narrows the search space much faster than blindly tuning retries.

1. Navigation never completed

Symptoms:

  • Page load times out.
  • The browser stays on a blank or partially rendered page.
  • The failure happens before the first assertion.

Likely causes:

  • Slow server response.
  • Blocked third-party scripts.
  • A navigation wait that assumes load instead of a more appropriate event.

2. The page loaded, but the target element was not ready

Symptoms:

  • locator.click() fails because the element is hidden or detached.
  • An assertion finds a placeholder instead of the final content.
  • The test sees the element, but interaction fails.

Likely causes:

  • Rendering and data fetching are not synchronized.
  • The UI uses skeleton states, lazy hydration, or client-side transitions.
  • The app marks an element visible before it is truly interactable.

3. The interaction succeeded, but the next state was late

Symptoms:

  • A click worked, but the follow-up assertion is too early.
  • A toast, table row, or route change appears after the timeout.

Likely causes:

  • Asynchronous side effects are still running.
  • The test is waiting for the wrong signal.
  • The app does not expose a stable post-action marker.

4. The failure is intermittent and changes shape

Symptoms:

  • Sometimes a selector is missing, sometimes a timeout, sometimes a stale element.
  • Browser automation retries change the error but do not remove it.

Likely causes:

  • Real race conditions.
  • Overlapping requests.
  • Tests sharing data or state.
  • A rendering bug that depends on request order.

Reproduce the problem with network throttling, not imagination

The most useful move is to force the failure in a controlled environment. Do not rely on the CI machine being “slow enough” by chance. Reproduce the issue with explicit network throttling and, if needed, CPU throttling as well.

Use browser devtools first

If you can reproduce manually, start there. Chrome DevTools and Firefox tools can simulate a slow connection, and they immediately show whether the issue is in the app or the test harness.

For manual debugging, you want answers to questions like:

  • Which request is still pending when the test clicks?
  • Does the UI show a loading state or a stale state?
  • Is the element present but covered by a spinner?
  • Does the app recover if you wait longer?

If the manual reproduction is impossible, the test may be wrong, or the real issue may be in the test environment rather than the browser code.

Reproduce in Playwright with throttled network

Playwright can simulate slower bandwidth and latency through the browser context or CDP session. For debugging, the goal is not perfect realism, it is repeatability.

import { test, expect } from '@playwright/test';
test('checkout flow under slow network', async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage();

await page.route(‘*/’, async route => { await new Promise(r => setTimeout(r, 200)); await route.continue(); });

await page.goto(‘https://example.com/checkout’); await expect(page.getByRole(‘heading’, { name: ‘Checkout’ })).toBeVisible(); });

This example adds artificial delay to requests, which is useful for surfacing race conditions. For more controlled browser-level throttling, use Chrome DevTools Protocol in a Chromium-based run.

typescript

const client = await context.newCDPSession(page);
await client.send('Network.enable');
await client.send('Network.emulateNetworkConditions', {
  offline: false,
  latency: 300,
  downloadThroughput: (500 * 1024) / 8,
  uploadThroughput: (200 * 1024) / 8
});

The exact numbers are less important than consistency. Pick a profile that is slow enough to expose the bug, then keep it stable across runs.

Reproduce in Selenium with a proxy or CDP where available

Selenium itself does not standardize network shaping across all browsers, so teams often use a proxy, containerized network shaping, or browser-specific DevTools hooks. For Chromium-based drivers, CDP can be used in Python through Selenium 4.

from selenium import webdriver

options = webdriver.ChromeOptions() driver = webdriver.Chrome(options=options)

params = { “offline”: False, “latency”: 300, “downloadThroughput”: 500 * 1024 / 8, “uploadThroughput”: 200 * 1024 / 8, }

driver.execute_cdp_cmd(“Network.enable”, {}) driver.execute_cdp_cmd(“Network.emulateNetworkConditions”, params)

driver.get(“https://example.com/checkout”)

If CDP is not available for your browser, network shaping at the container or host level is often better than nothing. The important point is to make the failure reproducible on demand.

Inspect request timing, not just page state

Slow-network debugging becomes much easier when you can see which request gates the UI. The browser UI often hides this relationship. A button may appear ready, but the data it needs is still pending. A route may change, but the new page may wait on an API that your test never watches.

Useful signals include:

  • Document navigation timing.
  • XHR and fetch completion.
  • Script and CSS asset timing.
  • Response status and retry behavior.
  • Whether the app uses cached data, service workers, or prefetching.

In Playwright, listening to requests and responses can reveal whether the test is racing the network or the app.

page.on('request', req => {
  if (req.resourceType() === 'xhr' || req.resourceType() === 'fetch') {
    console.log('>>', req.method(), req.url());
  }
});

page.on(‘response’, res => { if (res.request().resourceType() === ‘xhr’) { console.log(‘«’, res.status(), res.url()); } });

This is especially useful when the app uses multiple requests to populate one screen. If the test checks the table after the first response, but the table actually depends on two requests and a client-side transform, the failure is likely in the test’s synchronization, not the backend.

Look for race conditions between rendering and interaction

Many flaky E2E tests are really race conditions, not timeout problems. The test and the browser are both doing the right thing, just in the wrong order.

Typical examples:

Visible does not mean clickable

A button can be visible before it is enabled, before its overlay disappears, or before the element stops moving. Clicks can fail if the target is covered, transitioning, or detached.

Fix pattern:

  • Wait for the intended state, not only visibility.
  • Use role-based locators when possible.
  • Assert that the control is enabled and stable before clicking.

A route changed before the data was ready

Client-side navigation can render a shell quickly, then fill it later. A test that checks for the URL change may pass even though the page content is not ready.

Fix pattern:

  • Wait for a meaningful UI marker, such as a heading or row that depends on the loaded data.
  • Track the request that powers the new route.
  • Avoid asserting only on URL or shell existence.

The app schedules work after the event loop returns

Frameworks often batch updates, defer hydration, or schedule rendering work with timers. On a fast network, the delay is tiny. On a slow network, the gap widens enough for the test to outrun the UI.

Fix pattern:

  • Observe app-specific readiness indicators.
  • Replace “sleep and hope” waits with state-based waits.
  • If possible, expose a test-friendly event when the page becomes ready.

Don’t use browser automation retries as a diagnosis tool

Retries can reduce noise, but they are not a substitute for understanding the failure. A retry may hide the symptom without fixing the actual bug. In the worst case, retries make the suite slower and more expensive while leaving the race condition intact.

Use browser automation retries carefully:

  • Good use: temporary mitigation while you collect better diagnostics.
  • Bad use: making a broken selector or missing wait look stable.
  • Risky use: retrying entire scenarios that mutate shared data.

If a failure disappears on retry, ask what changed between attempts. Was the data loaded, did the page re-render, did a cached script arrive, or did the app recover from an internal race? That information is more valuable than the green rerun itself.

Prefer application-ready signals over generic waits

Generic waits such as fixed sleeps are tempting because they are simple. They are also the fastest way to make browser tests slower without making them more correct.

Better options include:

  • Waiting for a specific response that populates the page.
  • Waiting for a role-based element with the final text.
  • Waiting for a loading spinner to disappear only if that spinner is the real readiness signal.
  • Waiting for app state through a test hook when the team owns the frontend.

Here is a Playwright pattern that waits for the data request before asserting on the UI.

typescript

const dataPromise = page.waitForResponse(res =>
  res.url().includes('/api/orders') && res.ok()
);

await page.getByRole(‘button’, { name: ‘Refresh’ }).click();

await dataPromise;
await expect(page.getByRole('table')).toContainText('Order #1234');

This is better than waiting an arbitrary 5 seconds because it ties the test to the actual dependency.

Watch for asset loading problems, not just API delays

Slow networks do not only delay JSON responses. They also delay CSS, images, fonts, and JavaScript chunks. A test may fail because the application logic is fine, but the browser has not finished loading the resources needed for stable rendering.

Common asset-related failures include:

  • Layout shifts that move the target element after the test computes the click point.
  • Font swaps that change text width and break visual assertions.
  • Lazy-loaded JavaScript that registers event handlers late.
  • Images or icons that affect container size and overlay positioning.

If you are seeing odd “element intercepted” or “not clickable” errors, inspect whether a late-loading asset is changing the hit target. In some apps, the clickable region is not stable until fonts, icons, or stylesheets are done loading.

A browser test is not just waiting for HTML, it is waiting for the page’s final interactive shape.

Use the network layer to understand what the test actually depends on

Sometimes the fastest way to debug a failure is to map the UI step to its network dependency chain. Ask:

  • Which request must finish before this element should exist?
  • Does the UI depend on one request, or several sequential requests?
  • Is the response cached locally in some runs and not others?
  • Is a third-party request influencing layout or feature flags?
  • Is a service worker serving stale content?

If the answer is unclear, instrument the test or the app to print a small trace of request and state transitions. Even a few log lines can reveal that the test was waiting on the wrong thing.

For example, if a page loads shell content, then fetches user data, then fetches permissions, the correct readiness signal may be the permissions response, not the initial document load.

When timeouts are the right fix, make them specific

Sometimes the app genuinely needs more time under slower conditions. That does not mean you should inflate every timeout globally. Broad timeout increases make real problems harder to see.

Use specific timeouts when:

  • A known heavy page performs legitimate work under slow connections.
  • The test is running in a constrained environment that you control.
  • The timeout is tied to a single request or workflow, not the whole suite.

Avoid global increases when:

  • The test is already racing on a local machine.
  • The failure is caused by a bad selector or a stale element.
  • You have not identified the slow dependency.

A useful rule is to increase timeouts only after you can explain why the extra time is needed.

A practical debugging workflow for flaky E2E tests

If you need a repeatable way to attack these problems, use this sequence.

Step 1: Reproduce with controlled slow network

Use DevTools, a proxy, or browser automation to create a repeatable slow profile. If the failure does not reproduce, do not assume the issue is gone, it may just need a different profile or CPU pressure.

Step 2: Trace the relevant requests

Identify the request that gates the UI. Check whether the test waits for it, ignores it, or assumes it is done.

Step 3: Verify the UI readiness signal

Look for a stable post-load state, not only a visible element. The element might exist before it is ready for interaction.

Step 4: Remove accidental dependencies

Check whether the test depends on cached assets, prior state, or a previous test that warmed the browser.

Step 5: Replace arbitrary waits

Use explicit waits tied to requests or app state. If you cannot express the wait, that may be a sign the app needs a better test hook.

Step 6: Decide whether the app or the test is wrong

If the app genuinely exposes a button before it is usable, fix the UI. If the test is clicking too early, fix the test. If the network dependency is hidden and unstable, improve observability.

Example: debugging a delayed search results page

Suppose a search page fails only when the network is slow. The test enters a query, clicks search, and expects the results count immediately.

On a fast network, the API response returns before the assertion. On a slow network, the page briefly shows a loading state, then the count appears after the timeout.

A better test waits for the response and the final result text.

typescript

const results = page.waitForResponse(res =>
  res.url().includes('/api/search') && res.ok()
);

await page.getByRole(‘textbox’, { name: ‘Search’ }).fill(‘browser tests’);

await page.getByRole('button', { name: 'Search' }).click();

await results;

await expect(page.getByRole('status')).toHaveText(/\d+ results/);

If that still flakes, inspect whether the count is derived from a second request, whether the UI debounces input, or whether the page updates in multiple phases. That is the kind of detail slow networks reveal.

Example: catching a click that races a late-loaded overlay

A common failure is an overlay or skeleton screen disappearing slightly later than the test expects. The test sees the underlying button but the overlay still intercepts the click.

Instead of clicking immediately after visibility, assert the overlay is gone or the target is enabled.

typescript

await expect(page.locator('[data-testid="loading-overlay"]')).toBeHidden();
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await page.getByRole('button', { name: 'Save' }).click();

This is not just about test stability. If the overlay can still block real users, you may have found a product bug.

How CI logs can mislead you

CI logs often flatten an event-rich browser session into a few lines:

  • selector not found
  • timeout waiting for navigation
  • element not clickable
  • expected text not present

Those messages are useful, but they usually omit the cause chain. Was the element missing because the API request failed? Because the script chunk did not load? Because the network was slow enough that the assertion ran too early? Without request timing and state visibility, the log is just the final symptom.

That is why reproducing under constrained bandwidth is more valuable than staring at a one-line timeout. You want to see the order of events, not infer it after the fact.

A few anti-patterns to remove from your suite

If you maintain a browser automation suite, look for these habits:

  • Fixed sleeps after every click.
  • Global timeout increases after a flaky run.
  • Assertions on URL changes only.
  • CSS selectors that target layout classes instead of stable roles or test IDs.
  • Retrying whole end-to-end flows without tracing the first failure.
  • Tests that depend on warmed cache or preloaded auth state without making that dependency explicit.

Each of these can mask slow-network bugs instead of exposing them.

When to fix the app, not the test

Sometimes the test is right and the app is wrong. If a control becomes interactive before its data or handler is ready, the product has a real UX bug. Users on bad connections will feel it even if your tests pass with enough retries.

Consider fixing the app when:

  • Buttons are interactive before the page is usable.
  • Loading states disappear too early.
  • Navigation completes before critical data is available.
  • The UI gives no stable readiness cue.
  • The component design encourages race-prone behavior.

Testing and product stability are closely linked here. A test that fails on slow networks may be revealing a user experience problem, not just a test problem. That is exactly the kind of failure worth investigating.

Final checklist

When a browser test fails only on slow networks, use this checklist:

  • Reproduce with explicit network throttling.
  • Trace the network requests that gate the UI.
  • Identify whether the failure is navigation, rendering, or interaction related.
  • Replace generic waits with state-based waits.
  • Check for late-loading assets and overlays.
  • Treat retries as a temporary mitigation, not a diagnosis.
  • Decide whether the app needs a readiness signal or the test needs a better assertion.

The goal is not to make every test wait longer. The goal is to make the test observe the same readiness that a real user experiences, even when the network is slow.

If you do that well, flaky E2E tests become much easier to reason about, and the next time a browser test fails on a constrained connection, you will have a systematic way to find the real cause instead of guessing from CI logs.