|

Network Throttling and Offline Testing in Playwright

Your app works perfectly on your gigabit office Wi-Fi, then falls apart on a customer’s 3G phone in an elevator. Spinners hang forever, half-loaded data leaves the UI in a broken state, and “offline mode” silently does nothing. In this guide you will learn exactly how Playwright network throttling offline testing works in TypeScript so you can reproduce slow connections, simulate full network loss, and assert that your application degrades gracefully instead of breaking.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why test slow and offline networks at all?

Most end-to-end suites run against a fast, reliable connection, so they only ever exercise the happy path. Real users do not get that luxury. They load your single-page app on congested mobile networks, lose signal in tunnels, and switch between Wi-Fi and cellular mid-request. If you never test those conditions, three classes of bug ship straight to production:

  • Race conditions that only appear when a request takes 4 seconds instead of 40 milliseconds.
  • Missing loading and error states that look fine when responses are instant but flash broken UI on slow links.
  • Broken offline handling where service workers, retries, and “you are offline” banners were never actually verified.

Playwright gives you several complementary tools for this: full offline mode via the browser context, real CDP-level throttling for Chromium, and request-level interception with page.route to add artificial latency or fake failures on a per-endpoint basis. Let’s walk through each.

Simulating a full offline state

The simplest scenario is total network loss. Playwright contexts expose context.setOffline(true), which flips the browser’s online flag and rejects all network traffic exactly as if the cable were pulled. You can also start a context offline by passing offline: true to browser.newContext().

import { test, expect } from '@playwright/test';

test('shows an offline banner when the network drops', async ({ page, context }) => {
  // Load the app while still online so assets are cached.
  await page.goto('https://app.example.com/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Pull the plug.
  await context.setOffline(true);

  // The app should react to the 'offline' event.
  await page.getByRole('button', { name: 'Refresh data' }).click();
  await expect(page.getByText('You are offline')).toBeVisible();

  // Restore connectivity and confirm recovery.
  await context.setOffline(false);
  await page.getByRole('button', { name: 'Retry' }).click();
  await expect(page.getByText('You are offline')).toBeHidden();
});

A key detail: setOffline operates on the whole BrowserContext, so every page and popup in that context goes offline together. Because it is scoped to the context, you can keep one context online and another offline in the same test run, which is perfect for multi-user scenarios. If your app relies on the JavaScript navigator.onLine property and the online/offline window events, this is the most faithful way to drive them.

Real network throttling with the Chrome DevTools Protocol

Offline is binary. To emulate a slow connection rather than a dead one, you need throttling. Playwright does not ship a one-line “throttle to 3G” helper, but for Chromium it exposes a CDP session through browser.newBrowserCDPSession() or context.newCDPSession(page). The Network.emulateNetworkConditions command is exactly what Chrome DevTools uses behind its own throttling dropdown, so the behavior matches what you see in the browser.

import { test, expect, chromium } from '@playwright/test';

// Values mirror Chrome DevTools' built-in "Slow 3G" preset.
const SLOW_3G = {
  offline: false,
  downloadThroughput: (500 * 1024) / 8, // 500 kbps in bytes/sec
  uploadThroughput: (500 * 1024) / 8,
  latency: 400, // round-trip in ms
};

test('dashboard stays usable on Slow 3G', async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  // CDP is Chromium-only; attach a session to the page.
  const client = await context.newCDPSession(page);
  await client.send('Network.enable');
  await client.send('Network.emulateNetworkConditions', SLOW_3G);

  const start = Date.now();
  await page.goto('https://app.example.com/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({
    timeout: 30_000,
  });
  console.log('Loaded in', Date.now() - start, 'ms');

  await browser.close();
});

Because emulateNetworkConditions throttles every request the page makes, it is the closest thing to a true slow connection. The trade-off is that it only works in Chromium-based browsers; Firefox and WebKit have no equivalent CDP command. For cross-browser slow-network tests, fall back to request interception, which we cover next. Remember to bump your assertion timeouts: on a 400 ms-latency link, the default 5-second expectation timeout will fail spuriously.

Adding latency per request with page.route

page.route intercepts matching requests before they hit the network, letting you delay, modify, or abort them. This is the most portable way to slow things down because it runs identically across Chromium, Firefox, and WebKit. The trick is to await a delay inside the handler, then call route.continue() so the real request still goes out.

import { test, expect } from '@playwright/test';

test('shows a spinner while a slow API call is in flight', async ({ page }) => {
  // Delay only the calls we care about; leave assets untouched.
  await page.route('**/api/orders', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 3000));
    await route.continue();
  });

  await page.goto('https://app.example.com/orders');

  // While the 3s delay runs, the loading state must be visible.
  await expect(page.getByTestId('orders-spinner')).toBeVisible();

  // Once the response lands, the spinner is replaced by data.
  await expect(page.getByRole('row')).toHaveCount(11, { timeout: 10_000 });
  await expect(page.getByTestId('orders-spinner')).toBeHidden();
});

You can be surgical with the glob pattern: throttle **/api/** to slow your backend while CSS and images load fast, or target a single endpoint to test one widget’s loading state. To simulate failures instead of slowness, abort the route. route.abort('failed') mimics a connection drop, while route.fulfill lets you return a fake 500 so you can assert your error UI without touching the real server.

import { test, expect } from '@playwright/test';

test('renders an error state when the API fails', async ({ page }) => {
  // First, simulate a hard network failure (DNS/connection error).
  await page.route('**/api/profile', (route) => route.abort('failed'));
  await page.goto('https://app.example.com/profile');
  await expect(page.getByText('Could not load profile')).toBeVisible();

  // Now swap the handler to return a fake 503 and assert the retry path.
  await page.unroute('**/api/profile');
  await page.route('**/api/profile', (route) =>
    route.fulfill({
      status: 503,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Service Unavailable' }),
    }),
  );
  await page.getByRole('button', { name: 'Try again' }).click();
  await expect(page.getByText('Service is temporarily down')).toBeVisible();
});

Throttling, offline, and interception compared

Each technique covers a different need. Use this table to pick the right one for the behavior you want to verify.

TechniqueAPIBrowsersBest for
Full offlinecontext.setOffline(true)AllOffline banners, navigator.onLine, service workers
Real throttlingNetwork.emulateNetworkConditions (CDP)Chromium onlyFaithful Slow 3G / latency on every request
Per-request delaypage.route + setTimeoutAllCross-browser loading states, single-endpoint delays
Fake failuresroute.abort / route.fulfillAllError UI, retries, 4xx/5xx handling
Recorded trafficpage.routeFromHARAllDeterministic offline replay of real responses

Deterministic offline replay with HAR files

For true offline-first testing, you often want your app to load real backend data without ever hitting the network during the test. Playwright’s page.routeFromHAR records every response to a HAR file once, then replays it offline on subsequent runs. This makes flaky third-party APIs irrelevant and lets your suite run with no backend at all.

import { test, expect } from '@playwright/test';

test('runs fully offline by replaying recorded responses', async ({ page }) => {
  // 'update: missing' records the HAR on first run, then replays it.
  // 'notFound: abort' fails any request not present in the archive,
  // proving the test truly touches no live network.
  await page.routeFromHAR('./hars/dashboard.har', {
    url: '**/api/**',
    update: false,
    notFound: 'abort',
  });

  await page.goto('https://app.example.com/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await expect(page.getByRole('row')).toHaveCount(11);
});

To generate the archive in the first place, run once with update: true (or use the --save-har flag on the CLI) against a live environment, commit the resulting .har file, then switch to replay mode. Combine HAR replay with context.setOffline(true) for a brutal but valuable test: the network is genuinely off, yet your app still renders because every response is served from the archive.

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Putting it together as a reusable fixture

Repeating CDP setup in every test gets noisy. Wrap throttling in a Playwright fixture so any test can opt in with a single line. The fixture below exposes a slowNetwork helper that applies Slow 3G to the active page.

import { test as base, expect } from '@playwright/test';

type Fixtures = {
  slowNetwork: () => Promise<void>;
};

export const test = base.extend<Fixtures>({
  slowNetwork: async ({ context, page }, use) => {
    const apply = async () => {
      const client = await context.newCDPSession(page);
      await client.send('Network.enable');
      await client.send('Network.emulateNetworkConditions', {
        offline: false,
        downloadThroughput: (500 * 1024) / 8,
        uploadThroughput: (500 * 1024) / 8,
        latency: 400,
      });
    };
    await use(apply);
  },
});

test('checkout button is disabled until the slow cart loads', async ({
  page,
  slowNetwork,
}) => {
  await slowNetwork();
  await page.goto('https://app.example.com/cart');
  // Guard against double-submit while data is still arriving.
  await expect(page.getByRole('button', { name: 'Checkout' })).toBeDisabled();
  await expect(page.getByRole('button', { name: 'Checkout' })).toBeEnabled({
    timeout: 30_000,
  });
});

This keeps your specs readable while centralizing the network logic. You could extend the same fixture with presets for Fast 3G, Regular 4G, or an offline() helper that calls context.setOffline(true).

Practical tips and gotchas

  • Raise your timeouts. Under throttling, increase per-assertion timeouts and consider a higher navigationTimeout in your config, or slow tests will fail on timing alone.
  • Throttle, then navigate. Apply CDP conditions or register routes before page.goto so the very first load is affected.
  • Mind the context scope. setOffline and routes registered on a context apply to all its pages; routes on a page apply only to that page.
  • CDP is Chromium-only. For Firefox and WebKit coverage, rely on page.route delays instead of emulateNetworkConditions.
  • Clean up. Use page.unroute to remove handlers mid-test when you want to change behavior, as shown in the failure example above.

Mastering Playwright network throttling offline testing turns a whole category of “works on my machine” bugs into reproducible, automated checks. Start with context.setOffline for offline states, reach for CDP emulateNetworkConditions when you need faithful slow connections in Chromium, and lean on page.route and routeFromHAR for cross-browser delays, fake failures, and deterministic replay. Together they let you prove your app stays usable no matter how bad the connection gets.

FAQ

Can Playwright throttle the network in Firefox and WebKit?

Not via real bandwidth throttling. The Network.emulateNetworkConditions command relies on the Chrome DevTools Protocol, which only exists in Chromium. For Firefox and WebKit, simulate slow connections by adding artificial delays inside a page.route handler before calling route.continue(), which works identically across all three engines.

What is the difference between context.setOffline and route.abort?

context.setOffline(true) takes the entire browser context offline, fires the browser’s offline event, and flips navigator.onLine to false, so it tests your global offline handling. route.abort('failed') only fails the specific requests you intercept while the browser still believes it is online, which is ideal for testing a single endpoint’s error path without affecting the rest of the app.

How do I run Playwright tests completely offline?

Record real responses into a HAR file with page.routeFromHAR using update: true, commit the archive, then replay it with update: false and notFound: 'abort'. Optionally combine it with context.setOffline(true) so the browser is genuinely disconnected while your app still renders from the recorded data, giving you a fully deterministic, network-free test.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.