Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 067 / 100

Network throttling and offline tests: a practical guide

How does your app behave on bad networks?

Expona Admin8 min read

Network throttling and offline tests: a practical guide

Your e-commerce checkout works flawlessly on fiber, but what happens when a customer on a 3G connection tries to place an order? Network conditions are one of the most unpredictable variables in production, yet they're often the last thing we test. This practical guide shows you how to use Playwright to simulate offline scenarios and degraded networks, so you can verify that your app degrades gracefully rather than breaking silently.

Why network testing matters in production

We tend to test on localhost or fast staging environments. Real users, however, face dropped connections, slow mobile networks, timeouts, and intermittent failures. When your app can't reach an API, does it show a retry button or just hang? If a form submission stalls, can the user cancel and try again? These UX details separate a resilient application from a frustrating one.

Network throttling and offline tests reveal how your frontend handles adversity. You'll catch loading state bugs, retry logic failures, and poor error messaging before users encounter them. For travel and ride-hailing apps especially, network robustness can be the difference between a completed booking and an abandoned cart.

Offline mode: simulating complete disconnection

Playwright's context API gives you a simple way to take a page offline instantly. Use context.setOffline(true) to disable all network requests, or context.setOffline(false) to restore connectivity.

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

test('should show offline banner when connection drops', async ({ page, context }) => {
  await page.goto('https://example.com/dashboard');
  
  // Verify content loads normally
  await expect(page.locator('[data-testid="welcome-message"]')).toBeVisible();
  
  // Simulate offline
  await context.setOffline(true);
  
  // Wait a moment for the app to detect offline state
  await page.waitForTimeout(500);
  
  // Assert offline UI appears
  await expect(page.locator('[data-testid="offline-banner"]')).toBeVisible();
  await expect(page.locator('[data-testid="offline-banner"]')).toContainText('You are offline');
  
  // Restore connection
  await context.setOffline(false);
  await expect(page.locator('[data-testid="offline-banner"]')).toBeHidden();
});

This pattern works well for testing offline detection in real-time apps. If your app uses service workers or has offline-first sync logic, you can verify that queued actions persist and retry once the connection returns. The key is calling setOffline after the page has loaded initial content, so you're testing the offline transition, not just the offline state.

One common approach is to test that users can navigate back to cached pages while offline. For instance, if a user loaded the product listing, they should still see it offline, but clicking "Add to Cart" should either queue the action or show a message that the action requires a connection.

Slow responses: delaying routes with page.route

Complete offline is one extreme; slow networks are more subtle and insidious. A request that takes 30 seconds to complete might crash your app's timeout logic or cause users to tap buttons repeatedly. Use page.route to intercept requests and add artificial delays, simulating slow 3G, 4G, or satellite connections.

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

test('should show loading spinner during slow API call', async ({ page }) => {
  // Intercept all API calls and delay them by 3 seconds
  await page.route('**/api/**', async (route) => {
    // Add a 3-second delay to simulate slow network
    await page.waitForTimeout(3000);
    await route.continue();
  });
  
  await page.goto('https://example.com/checkout');
  
  // Trigger a slow API call
  await page.click('button:has-text("Confirm Order")');
  
  // Assert loading state appears immediately
  await expect(page.locator('[data-testid="loading-spinner"]')).toBeVisible();
  
  // Verify the spinner is still there after 2 seconds
  await page.waitForTimeout(2000);
  await expect(page.locator('[data-testid="loading-spinner"]')).toBeVisible();
  
  // Wait for response and verify success state
  await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible({ timeout: 5000 });
  await expect(page.locator('[data-testid="loading-spinner"]')).toBeHidden();
});

You can be more granular by targeting specific endpoints. For example, delay only image uploads while keeping metadata requests fast, or simulate timeout by never responding:

typescript
// Simulate timeout by never responding
await page.route('**/api/slow-endpoint', (route) => {
  // Don't call route.continue() or route.abort()
  // This hangs the request indefinitely
});

// Or abort with a specific error
await page.route('**/api/upload', (route) => {
  route.abort('failed');
});

This technique is invaluable for testing SaaS dashboards where real-time data fetches might overlap, or e-commerce sites where image-heavy product pages need to handle staggered loading.

Assert UX: loading states, retries, and offline banners

Now that you can trigger offline and slow conditions, the real work is verifying your UI handles them correctly. This means testing three core behaviors: loading indicators, retry logic, and error messages.

Loading states

A good loading state tells users that something is happening. It might be a spinner, a skeleton, a progress bar, or simply disabled buttons. When you delay an API call, verify the loader appears before the response completes:

typescript
test('loading state appears before response', async ({ page }) => {
  let requestStarted = false;
  
  await page.route('**/api/data', async (route) => {
    requestStarted = true;
    await page.waitForTimeout(2000);
    await route.continue();
  });
  
  await page.goto('https://example.com');
  await page.click('button:has-text("Load Data")');
  
  // The loader should be visible almost immediately
  await expect(page.locator('[data-testid="loader"]')).toBeVisible({ timeout: 100 });
  
  // Once data arrives, loader disappears
  await expect(page.locator('[data-testid="loader"]')).toBeHidden({ timeout: 3000 });
  await expect(page.locator('[data-testid="data-container"]')).toContainText('Expected content');
});

Retry mechanisms

For critical operations like payments or data submissions, retry logic is essential. Test that retries happen automatically or that a user-visible retry button exists:

typescript
test('should show retry button on failed submission', async ({ page }) => {
  let attemptCount = 0;
  
  await page.route('**/api/submit', (route) => {
    attemptCount++;
    if (attemptCount < 3) {
      route.abort('failed');
    } else {
      route.continue();
    }
  });
  
  await page.goto('https://example.com/form');
  await page.fill('input[name="email"]', 'user@example.com');
  await page.click('button:has-text("Submit")');
  
  // First attempt fails; error message shows
  await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
  
  // Retry button is available
  await expect(page.locator('button:has-text("Retry")')).toBeEnabled();
  
  // User clicks retry
  await page.click('button:has-text("Retry")');
  
  // Second attempt still fails
  await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
  
  // User clicks retry again; this time it succeeds
  await page.click('button:has-text("Retry")');
  await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});

Offline banners and messages

Many modern apps show a banner or toast when the network state changes. This is a core UX pattern worth testing end-to-end:

typescript
test('should display and dismiss offline notification', async ({ page, context }) => {
  await page.goto('https://example.com');
  
  // Go offline
  await context.setOffline(true);
  
  // Offline banner appears
  const offlineBanner = page.locator('[role="alert"]:has-text("Connection lost")');
  await expect(offlineBanner).toBeVisible();
  
  // User can dismiss it
  await page.click('button[aria-label="Dismiss"]');
  await expect(offlineBanner).toBeHidden();
  
  // Come back online
  await context.setOffline(false);
  
  // Verify the app automatically recovers (e.g., retries pending requests)
  await expect(page.locator('[data-testid="data-refreshed"]')).toBeVisible();
});

CDP throttling: low-level network emulation

For advanced scenarios, Playwright supports Chrome DevTools Protocol (CDP) network emulation. This lets you simulate realistic network profiles like "Fast 3G" or "Slow 4G" with latency and bandwidth limits.

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

test('should handle slow 3G gracefully', async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  
  // Enable CDP session for network emulation
  const page = await context.newPage();
  const client = await page.context().ensureSession();
  
  // Simulate Fast 3G
  await client.send('Network.emulateNetworkConditions', {
    offline: false,
    downloadThroughput: 1.6 * 1024 * 1024 / 8, // 1.6 Mbps
    uploadThroughput: 750 * 1024 / 8, // 750 Kbps
    latency: 150 // ms
  });
  
  const startTime = Date.now();
  await page.goto('https://example.com');
  const loadTime = Date.now() - startTime;
  
  console.log(`Page loaded in ${loadTime}ms on simulated 3G`);
  
  // Assert it still works
  await expect(page.locator('[data-testid="main-content"]')).toBeVisible({ timeout: 30000 });
  
  await browser.close();
});

CDP emulation is more realistic than simple route delays because it affects all network traffic—images, scripts, stylesheets—giving you a true sense of how your app behaves on throttled connections. However, it requires accessing the context's session, which is more fragile across Playwright versions.

Common mistakes

  • Testing offline after user interactions complete: Going offline mid-navigation is more realistic than starting offline. Test the transition, not just the state.
  • Not accounting for UI debouncing: If your offline banner uses a debounced state update, give it time to render. A 500ms wait is often safe.
  • Forgetting to restore connectivity: If you use context.setOffline(true) without restoring it, subsequent tests in the same context will remain offline. Always pair it with setOffline(false) or use a fixture.
  • Route interception blocking everything: If a route handler never calls route.continue(), the page hangs. Always explicitly continue, abort, or respond.
  • Ignoring cache behavior: Offline tests are most meaningful if the app has some cached content. Test with a fresh load (no cache) and then offline; the second scenario is more realistic.

Key takeaways

  • context.setOffline(true) simulates complete disconnection; use it to test offline UI and service worker behavior.
  • Use page.route() to intercept and delay requests, simulating slow networks and testing loading states.
  • Always verify that your app shows loaders, offers retries, and displays clear error messages under adverse conditions.
  • CDP network emulation (Network.emulateNetworkConditions) provides realistic, bandwidth-aware throttling for complex scenarios.
  • Test the offline transition, not just the offline state; users experience network changes mid-session, not app loads.
  • Network tests are essential for travel, ride-hailing, and e-commerce apps where unreliable connections are common.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles