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.
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.
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:
// 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:
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:
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:
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.
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 withsetOffline(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.






Comments
Loading comments…