Skip to main content
Cypress App

Native Network Interception

Starting in Cypress 16, Chrome, Chromium, and Edge intercept test traffic on the native browser network. Your application connects to your server directly and negotiates whatever protocol the server supports, exactly as it does in production.

That closes the biggest remaining gap between how your application behaves under test and how it behaves for your users:

  • Your production protocol is the one under test. HTTP/2 and HTTP/3 connections are no longer downgraded to HTTP/1.1, so requests multiplex over a single connection instead of queueing behind the six-connection-per-origin ceiling. Request-heavy pages load faster under test, and protocol-specific behavior is finally reachable from a test.
  • Your certificate is the one the browser validates. Cypress no longer terminates TLS for the application under test, acts as its own certificate authority, or generates a certificate for your origin.
  • What you stub is what the browser sees. Modified request headers and bodies, stubbed responses, and overridden status codes all appear in the browser's own network panel, because the browser is the one making the request.

cy.intercept() works as before. You can match, stub, modify, and wait on requests with the same API, and most suites need no edits.

What changed​

Before Cypress 16, Cypress routed every request your application made through the legacy network path. That path is how Cypress observed, stubbed, and recorded network traffic, and it only supported HTTP/1.1. Requests that your server would have served over HTTP/2 or HTTP/3 in production were downgraded to HTTP/1.1 during a Cypress run.

Because Cypress is no longer the connection between your browser and your server, a small number of behaviors change. Each one is listed on this page with a before-and-after example:

BehaviorWhat to do
content-encoding is not reported on responsesAssert on the decoded body instead of the compression headers.
httpVersion is not reportedAssert on request properties that describe your application.
Responses the browser rejects are not observableAssert on the error state your application renders.
Responses served without a network request are not cachedExpect the intercept to see the request again on a second navigation.
Stubbed bodies are decoded from their declared content-encodingMake sure a declared encoding matches the bytes you supply.
Compressed documents from insecure origins loadUpdate tests that expected the visit itself to fail.
Revalidated responses report 200 instead of 304Use cy.request() to assert on caching behavior.
responseTimeout does not apply to response handlersBound the wait with a timeout on cy.wait().
Request content-length is not reportedAssert on the body you set.
Your application's certificate is usedNothing. This is the improvement.
cy.intercept() changes are visible in the browserNothing. This is the improvement.

Which browsers are affected​

The interception mechanism is chosen per browser, so a suite that runs in more than one browser can see both behaviors in the same run.

BrowserforceHttp1: false (default)forceHttp1: true
Chrome, Chromium, EdgeNative browser networkLegacy network path
Firefox, WebKitLegacy network pathLegacy network path
ElectronLegacy network pathLegacy network path

Firefox and WebKit continue to use the legacy network path because Cypress has not implemented a native browser network path for them yet. Electron also uses the legacy network path; see Electron is deprecated as a test browser.

None of the behavior differences on this page apply to Firefox, WebKit, or Electron.

Using the legacy network path​

If a suite depends on the previous behavior and you need time to update it, or if you hit behavior on the native browser network that this page does not describe, set forceHttp1 to true. Every browser then uses the legacy network path and the Cypress 15 behavior described throughout this page.

const { defineConfig } = require('cypress')

module.exports = defineConfig({
forceHttp1: true,
})

Changing forceHttp1 restarts the Cypress server, and the option cannot be set per test or per suite.

If setting forceHttp1 to true allows your suite to pass, and the difference is not one of the documented behaviors below, please open an issue with what you are experiencing so it can be investigated.

caution

forceHttp1 is both introduced and deprecated in Cypress 16. It exists only to give suites time to migrate, or as a temporary escape hatch while an issue you have filed is fixed, and it will be removed once the native browser network is supported long term. Do not set it to true only to avoid updating tests for the native browser network, such as skipping Cypress.isBrowser() gates. Treat it as a temporary aid, not a permanent setting.

Behavior differences​

Keep these behaviors in mind when a suite runs in Chrome, Chromium, or Edge on the native browser network.

Response headers do not report content-encoding​

In Cypress 15 and earlier, an intercepted response gave you a decoded body along with the compression headers from the network exchange, so response.body held readable text while response.headers['content-encoding'] still said br or gzip. The header described bytes you never received.

In Cypress 16 on the native browser network, an intercepted response reports a decoded body and no compression headers. content-encoding, content-length, and transfer-encoding are not present on response.headers, so the headers describe the body that accompanies them.

// Cypress 15 and earlier
cy.intercept('/assets/app.js').as('appJs')
cy.visit('/')
cy.wait('@appJs').then(({ response }) => {
expect(response.headers['content-encoding']).to.equal('br')
expect(response.body).to.contain('function')
})
// Cypress 16
cy.intercept('/assets/app.js').as('appJs')
cy.visit('/')
cy.wait('@appJs').then(({ response }) => {
expect(response.headers).to.not.have.property('content-encoding')
expect(response.body).to.contain('function')
})

Compression is negotiated between the browser and your server, and the body Cypress hands you has already been decoded. Assert on the body itself rather than on the encoding headers.

httpVersion is not reported​

req.httpVersion is undefined and res.httpVersion is null in Chrome, Chromium, and Edge on the native browser network. Firefox, WebKit, and Electron use the legacy network path and still report '1.1'.

On the legacy network path, the value was always '1.1', because it described the hop between the browser and Cypress rather than the protocol your server used. The browser now negotiates directly with your server on the native browser network, so the protocol depends on what that server supports (HTTP/1.1, HTTP/2, or HTTP/3). It is not known at the point the request is intercepted, so Cypress reports no value instead of one that could be wrong.

// Cypress 15 and earlier
cy.intercept('/api/users', (req) => {
expect(req.httpVersion).to.equal('1.1')
})
// Cypress 16: assert on the request properties that describe your application
cy.intercept('/api/users', (req) => {
expect(req.method).to.equal('GET')
expect(req.headers).to.have.property('accept')
})

If a cross-browser suite needs the assertion for browsers on the legacy network path, see Writing tests that pass in every browser.

Responses the browser rejects are not observable​

When the browser's network stack refuses a response before delivering it (for example, a response it will not decode), the response never reaches Cypress. The interception still records the request, and interception.response is undefined.

On the legacy network path, Cypress used to receive that response itself, so it could report a status and headers even for a load that then failed in the browser. On the native browser network, the status and headers stay inside the browser's network stack and are never surfaced.

// Cypress 15 and earlier
cy.intercept('/assets/broken.js').as('brokenJs')
cy.visit('/page-with-a-rejected-asset')
cy.wait('@brokenJs').its('response.statusCode').should('equal', 200)
// Cypress 16: the request is captured, the response is not
cy.intercept('/assets/broken.js').as('brokenJs')
cy.visit('/page-with-a-rejected-asset')
cy.wait('@brokenJs').then((interception) => {
expect(interception.request.url).to.contain('/assets/broken.js')
expect(interception.response).to.be.undefined
})

To assert that a resource failed to load, assert on your application instead, for example on the error state it renders.

Responses served without a network request are not cached by the browser​

Some responses are answered inside Cypress and never involve a network exchange:

  • Responses stubbed by cy.intercept() before the request is sent, such as a fixture or a static body.
  • Documents loaded by cy.visit() on http origins.
  • Responses whose body a request or response handler modified.

The browser does not store these in its HTTP cache, because there was no network response to cache. A second navigation issues the request again and your intercept sees it again, even if the stub declared caching headers.

cy.intercept('/api/config', {
fixture: 'config.json',
headers: { 'cache-control': 'max-age=3600' },
}).as('config')

cy.visit('/')
cy.reload()

// Both navigations reach the intercept in Cypress 16
cy.get('@config.all').should('have.length', 2)

Tests that assert a resource was served from cache, rather than asserting on what your application rendered, will be affected.

Stubbed bodies are decoded from their declared content-encoding​

When a stub declares a content-encoding header, Cypress decodes the body so the browser renders it. gzip, x-gzip, deflate, and br are all decoded. A zstd stub is passed through with the header and body intact, as before.

If the bytes do not actually match the declared encoding, the decode fails inside Cypress and the request errors. In Cypress 15, the mismatched pair reached the browser, which rejected it there. The underlying mistake is the same in both versions; only the place the error surfaces has changed.

// Declares gzip, so the body must really be gzip bytes
cy.intercept('/api/data', {
headers: { 'content-encoding': 'gzip' },
body: gzippedBytes,
})

Compressed documents from insecure origins load​

Browsers refuse to decode br responses from insecure origins (http origins other than localhost). On the legacy network path, the document reached the browser still compressed and the visit failed.

On the native browser network in Cypress 16, cy.visit() on an http origin resolves and decodes the document before handing it to the browser, so the visit completes. Scripts, stylesheets, and other subresources are still fetched by the browser and still hit the same insecure-origin policy, matching production.

A test that expected the visit itself to fail may now wait for a failure that never comes and time out instead.

// Cypress 15 and earlier: the browser refused to decode the document, so the
// visit never completed
cy.visit('http://www.example.com/br/html', { timeout: 500 })
cy.on('fail', (err) => {
expect(err.message).to.contain('Timed out after waiting `500ms`')
})
// Cypress 16: the document loads decoded, and its br subresources still fail
cy.visit('http://www.example.com/br/html')

cy.get('#content').should('contain.text', 'hello from brotli')

// the br script and stylesheet still hit the browser's insecure-origin policy
cy.get('#from-script').should('have.text', '')
cy.get('#content').then(($el) => {
expect(getComputedStyle($el[0]).fontSize).not.to.eq('42px')
})

When you update these tests, assert on both the document and the subresources. Only the document load changed; the insecure-origin policy still applies to everything the browser fetches on its own.

The Cypress 15 failure also varied by browser: Chromium timed out on the load, while Firefox rendered the raw compressed bytes as garbled text. Firefox stays on the legacy network path, so that behavior is unchanged.

Revalidated responses report 200 instead of 304​

When the browser already holds a cached copy of a resource, it revalidates with a conditional request and your server answers 304 Not Modified. On the legacy network path, Cypress made that request itself, so the intercept saw the 304.

On the native browser network, the browser's cache sits between your server and Cypress. The browser sends the conditional request, receives the 304, merges it with the copy it already had, and hands Cypress the complete response. Your server still answers 304, but Cypress no longer observes it.

// Cypress 15 and earlier
cy.intercept('/assets/logo.png').as('logo')
cy.wait('@logo').its('response.statusCode').should('equal', 200)
// the second load revalidates and your server answers 304
cy.wait('@logo').its('response.statusCode').should('equal', 304)
// Cypress 16: both loads report the complete response
cy.intercept('/assets/logo.png').as('logo')
cy.wait('@logo').its('response.statusCode').should('equal', 200)
cy.wait('@logo').its('response.statusCode').should('equal', 200)

To assert on caching behavior itself, request the resource with cy.request(), which goes through Cypress rather than the browser cache and reports the status your server sent.

responseTimeout does not apply to response handlers​

When an intercept supplies a response handler, Cypress 15 and earlier fetched the upstream response itself and applied responseTimeout to that fetch. A slow endpoint failed the test with a timeout error naming responseTimeout.

On the native browser network, the browser makes that request, so there is no Cypress request for responseTimeout to bound. A response handler runs when your server answers. Cypress still gives up if no response arrives within 30 seconds, but that limit is fixed and is not the value you configure.

// Cypress 15 and earlier: failed after responseTimeout elapsed
cy.intercept('/api/slow', (req) => {
req.reply((res) => {
res.send()
})
})
// Cypress 16: bound the wait in your test instead
cy.intercept('/api/slow').as('slow')
cy.visit('/')
cy.wait('@slow', { timeout: 10000 })

responseTimeout still applies to every command documented as using it, including cy.request() and cy.wait(). Only cy.intercept() response handlers are affected.

Request content-length is not reported​

req.headers['content-length'] is not present on an intercepted request. The browser computes that header when it sends the request, which happens after your intercept runs, so the value does not exist yet at the point Cypress hands you the request. Your server still receives an accurate content-length.

This also means Cypress no longer recalculates the header when a handler replaces req.body. The browser sends the real length of whatever body your handler set.

// Cypress 15 and earlier
cy.intercept('/api/users', (req) => {
req.body = ''
expect(req.headers['content-length']).to.equal('0')
})
// Cypress 16: assert on the body you set
cy.intercept('/api/users', (req) => {
req.body = ''
expect(req.body).to.equal('')
})

Your application's certificate is used​

Cypress no longer terminates TLS for the application under test, so the browser makes its own connection and validates your origin's real certificate. Chrome no longer reports a certificate issued by an unknown authority, and Cypress does not generate one.

The browser still reports the page as not secure, because the Cypress app itself is served over http on localhost. That indicator describes the Cypress app, not your application loading inside it.

On the legacy network path Cypress acts as its own certificate authority and issues a certificate for the origin under test, which the browser reports as untrusted.

cy.intercept() changes are visible in the browser​

The browser sends the request and receives the response, so everything an intercept changes is what the browser itself records: modified request headers and bodies, stubbed or rewritten responses, and overridden status codes all appear in the browser's network panel. The Authorization header attached by cy.visit() appears there too.

On the legacy network path, request modifications are applied after the browser has already logged the request, so they do not appear in the network panel. Response modifications do, because the browser only ever sees the response Cypress returns.

Assertions on request.headers after cy.wait() and the Cypress command log report the modified request on both paths.

Writing tests that pass in every browser​

Assertions that describe your application rather than the transport pass in every browser and on both network paths. Prefer asserting on the response body, on the rendered page, or on the request your application sent instead of transport metadata such as req.httpVersion or compression headers.

If you must keep an assertion that differs between the native browser network and the legacy network path, and your suite runs Chrome, Chromium, or Edge alongside Firefox, WebKit, or Electron, gate it with Cypress.isBrowser():

const usesNativeBrowserNetwork = Cypress.isBrowser({
family: 'chromium',
name: '!electron',
})

cy.intercept('/api/users', (req) => {
if (!usesNativeBrowserNetwork) {
expect(req.httpVersion).to.equal('1.1')
}
})

Excluding Electron by name is what makes the check match the network path. Electron is Chromium-based, so a family: 'chromium' filter on its own also matches Electron and would put it on the wrong side of the gate.

Do not set forceHttp1 to true only to avoid this gate; see Using the legacy network path.

See also​