The network in E2E is two different boundaries, and they must not be confused. There is your own boundary - the frontend, your API, your database - whose connectivity the test is meant to prove. And there is someone else's boundary - a payment provider, maps, weather, an SMS gateway - unstable, paid or dangerous. The rule is simple: keep your own real, keep others' controlled. All the skill of working with the network in Playwright comes down to not confusing these two sides.
Your own boundary is most often needed for preparing data, and here request helps - Playwright's HTTP client. Through it the test makes a real request to your test API: creates an order, checks response.ok(), takes the created entity from json(). This is the same technique as in the data chapter, but it underscores the essence: the request goes to your real backend, not a mock. Preparing data via the API is faster and more reliable than clicking its creation through in the UI.
APIRequestContext is useful in itself too, not only for setup. Through the same request you can check the API directly - without a browser, at the HTTP level: send a request, check the status, body, headers. This does not replace E2E through the UI but complements it: some contracts are cheaper and more precise to check at the API level, leaving to browser scenarios what really requires the interface.
Someone else's boundary is controlled by intercepting the network. page.route catches requests by a URL pattern and lets you answer for the external service via route.fulfill - with a given status, content type and body. So the test rids itself of dependence on an unstable external service: instead of a real call to maps or weather, the page gets a controlled, predictable response, and the scenario stops depending on the availability and whims of a third party.
It helps to see both sides side by side once - preparation via your API and control of an external service via route. Below is creating an order with a real request to your backend and replacing the response of a third-party maps service. You return to this pair each time a network interaction appears in a scenario and you must decide which boundary it is - yours or someone else's.
The main network mistake is mocking the subject of the check. If an E2E test must prove that your frontend and your backend work together correctly, intercepting your own API is not allowed: by replacing it, the test checks the mock, not the integration it was written for. A mock is justified at an external unstable boundary, not your own. Confusing this means getting a green test that proves nothing about your system's connectivity.
Hence a working criterion: you may mock what you do not own and are not checking with this test. A payment gateway in an order-placement scenario - if the test is about the cart UI, not the payment itself - is reasonably replaced with a controlled response. But if the test is precisely about the order reaching your backend and being saved, your API must be real. The boundary of a mock runs along what exactly the scenario proves.
The typical network failures come down to confusing boundaries. Intercepting your own API in a test meant to prove integration - a green but empty result. A real call to an unstable third-party service without route - flakiness from someone else's unavailability, 429s and timeouts. And mocking the whole network at once instead of a pointed replacement of the external boundary, because of which the test stops checking the real system. Your own real, others' controlled, and always by what the scenario proves.
// Your boundary: preparation with a real request to your backend
test('shows the created order', async ({ request, page }) => {
const response = await request.post('/api/test/orders', {
data: { status: 'paid', total: 1490 },
})
expect(response.ok()).toBeTruthy()
const order = await response.json()
await page.goto(`/orders/${order.id}`)
await expect(page.getByText('Paid')).toBeVisible()
})// Someone else's boundary: a controlled response instead of the real service
await page.route('https://maps.example.com/**', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ city: 'Tashkent' }),
}),
)