Cypress: Network, Stubs, Spies & Clocks

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A browser test is only as repeatable as the world it runs in. The server may be slow, down or return different data tomorrow, the clock keeps moving, and some code paths — a failed save, a dropped connection, a dialog — are hard to reach by clicking. Cypress gives a test control over all of these. cy.intercept() sits on the application’s HTTP traffic: it can observe a request, wait for it, change it, answer it with a stub or let it through and edit the response. cy.fixture() supplies the data for those stubs, and cy.request() talks to the server straight from Node.js, with no page involved. cy.stub() and cy.spy() replace or watch JavaScript functions, and cy.clock() and cy.tick() take over the page’s timers and Date.

This page was written against Cypress 16.2.0 and checked on 25 October 2026. Every TypeScript example below is the text of a spec that was run against Bookshelf, the demo application described in Example Application & Test Layout. The specs ran (42 tests, all passing) in Electron 146 and in Chrome 155 on macOS. The one spec that depends on the network path was also run in Firefox 158 inside the cypress/included:16.2.0 Docker image. WebKit (experimental in Cypress) was not exercised. Snippets adapted from the official documentation for features Bookshelf does not have (GraphQL, file uploads, HTTPS, HTTP/2) are marked as not run in the sentence that follows them. Routes, test ids and the REST API are those described on the example-application page, and the selector and page-object style follows Test Design & Page Objects. Waiting, retry-ability and the command queue are explained in Core Concepts; sessions and login are in Sessions, Authentication & Cross-Origin.

Where each tool sits

Four different things get called "network" in a Cypress test, and they behave differently. Mixing them up is the source of most intercept confusion.

Tool What it affects What it cannot see

cy.intercept()

Requests the application under test makes in the browser (fetch, XHR, navigation, scripts, images, …​).

Anything the test itself sends with cy.request(); requests served from the browser cache; service-worker internals.

cy.request()

An HTTP request sent from the Cypress server process (Node.js), with the browser’s cookies attached.

It never appears in the browser’s DevTools network tab, and cy.intercept() routes do not match it.

cy.stub() / cy.spy()

JavaScript functions in the test or in the application’s window (for example window.fetch).

Anything that bypasses the function (a different reference, XMLHttpRequest, a navigation).

cy.clock() / cy.tick()

The page’s Date, timers, animation-frame callbacks and performance.now().

The server’s clock, CSS animations and transitions, iframe contents, and Cypress’s own command timeouts.

flowchart LR App["Application in the browser"] Hooks["cy.intercept routes (Cypress server)"] Proxy["Cypress HTTP/1.1 proxy"] Server["Your server"] Test["Test code: cy.request"] App -->|"native path: Chrome, Chromium, Edge"| Server App -->|"legacy path: Firefox, WebKit, Electron, forceHttp1"| Proxy Proxy --> Server App -.->|"request and response events"| Hooks Test -->|"sent from Node.js"| Server

The diagram shows why the next sections matter. Since Cypress 16 the browser talks to your server directly on Chrome, Chromium and Edge, and Cypress still sees every request and response through the browser; on the other browsers all traffic still flows through Cypress’s own proxy. cy.request() takes neither route.

The test project

The examples share one small project. Its configuration only needs the base URL, which comes from the environment so the same specs run against a local server, a container or a staging host:

// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
    video: false,
    screenshotsFolder: 'cypress/screenshots',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    supportFile: 'cypress/support/e2e.ts',
    setupNodeEvents(on) {
      on('task', {
        print(message: unknown) {
          console.log(JSON.stringify(message));
          return null;
        },
      });
    },
  },
});

The reader’s password is not in the file. Cypress 16 removed Cypress.env(); a secret is supplied as a CYPRESS_* environment variable and read inside a test with cy.env(), which keeps it in the Node.js process instead of copying it into the browser. The signIn command used by every spec reads READER_PASSWORD that way, logs in through the API once and caches the cookie with cy.session():

// cypress/support/commands.ts
Cypress.Commands.add('signIn', (username: string) => {
  cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
    cy.session(
      username,
      () => {
        cy.request('POST', '/api/session', { username, password: READER_PASSWORD });
      },
      {
        validate: () => {
          cy.request('/api/session').its('body.authenticated').should('eq', true);
        },
      },
    );
  });
});
BASE_URL=http://localhost:3924 CYPRESS_READER_PASSWORD="$BOOKSHELF_READER_PASSWORD" \
  npx cypress run --browser chrome

Every beforeEach below also calls POST /api/test/reset before signing in. The reset clears the server’s sessions, which is why signIn has a validate function: the cached cookie is re-checked and the login re-run when it no longer works.

cy.intercept(): the shapes of a call

cy.intercept() registers a route: a matcher plus an optional handler. It yields null, but the route can be given an alias with .as(), and an aliased route is what cy.wait() waits on. All routes are cleared automatically before every test, so a route never leaks into the next test.

Call Meaning

cy.intercept(url), cy.intercept(method, url), cy.intercept(routeMatcher)

Spy only: match requests, let them through, record them.

cy.intercept(url, staticResponse) (also with a method, or with a routeMatcher)

Stub: answer matching requests with a fixed response and never contact the server.

cy.intercept(url, routeHandler) (also with a method, or with a routeMatcher)

Dynamic: a function receives each request and can modify it, reply, continue to the server and edit the response, or just assert.

cy.intercept() accepts generic type arguments for the request and response bodies, for example cy.intercept<CustomRequest, CustomResponse>(url, handler), so req.body and res.body are typed in the handler.

Matching requests

A URL given as a string is matched against the full request URL with the minimatch glob library ({ matchBase: true }); if the full URL does not match, Cypress also tries the path, so '/api/books*' works without a host. A RegExp is matched as written. Every property of a routeMatcher that you set must match.

routeMatcher property Matches

url

The full request URL (string glob or RegExp).

method

The HTTP method, case-insensitively. A glob or a RegExp can match several methods. Without a method, every method matches.

hostname, port, https

The host, the port (a number or an array) and true for HTTPS only or false for HTTP only.

path / pathname

The path after the host, with the query string (path) or without it (pathname).

query

The parsed query string as an object. Each parameter compares against a single string, so it cannot match repeated array-style parameters.

headers, auth

Request headers (names compare case-insensitively) and HTTP Basic credentials.

times

Match at most this many requests, then stop matching.

middleware

true puts the route first, in definition order, and requires a handler function.

resourceType

The kind of request (document, fetch, xhr, script, …​). Deprecated since 14.0.0.

Two matching pitfalls are worth remembering. In glob syntax ? is a wildcard for one character, so the ? that starts a query string must be escaped as a backslash-escaped question mark (written with two backslashes in a JavaScript string) — or avoided by matching pathname and query separately, which is also more robust for encoded query strings. And a pattern must cover the whole URL: '/api/users' does not match /api/users?page=1, while '/api/users*' does. Cypress.minimatch(url, pattern) lets you test a pattern in the spec or in the browser console.

Order and lifecycle

Routes that match a request run in this order: routes defined with middleware: true first, in the order they were defined, then all other routes starting with the one defined last. A route stops the chain when it has a static response or its handler calls req.reply() or req.continue(); a handler that does neither passes the request on to the next matching route. When no route is left, the request goes to the server. The consequence used throughout this page: a route defined later in a test overrides an earlier one from beforeEach, and nothing needs to be undone.

A route only sees requests sent after it is defined, so call cy.intercept() before the cy.visit() or the click that triggers the request. An intercept also never sees a request the browser answers from its own cache, because that request never reaches the network; when that is a problem, remove the cache headers from the responses in a top-level middleware route or turn caching off on the development server.

Spying and waiting on requests

A route with no handler is a spy: the request goes to the server untouched and Cypress records the exchange. Giving it an alias and calling cy.wait('@alias') turns a vague "wait until the page looks right" into "wait for the request that causes the page to look right", which removes a whole class of flaky tests and puts the failure where the cause is (the request never went out, or went to the wrong URL) rather than in a missing element.

describe('cy.intercept as a spy', () => {
  beforeEach(() => {
    cy.request('POST', '/api/test/reset');
    cy.signIn('reader');
  });

  it('waits for the review request and asserts on both sides of it', () => {
    // Register the route before the action that triggers the request.
    cy.intercept('POST', '/api/reviews').as('postReview');

    cy.visit('/books/3');
    cy.get('[data-testid=review-rating-input]').select('4');
    cy.get('[data-testid=review-text-input]').type('A lovely, detailed field guide.');
    cy.get('[data-testid=review-submit]').click();

    cy.wait('@postReview').then(({ request, response }) => {
      expect(request.body).to.deep.equal({
        bookId: 3,
        rating: 4,
        text: 'A lovely, detailed field guide.',
      });
      expect(response?.statusCode).to.eq(201);
      expect(response?.body).to.include({ author: 'reader', rating: 4 });
    });
    cy.get('[data-testid=review]').last().should('have.attr', 'data-state', 'saved');
  });

  it('reads the interception again with cy.get and the .all suffix', () => {
    cy.intercept('POST', '/api/reviews').as('postReview');
    cy.visit('/books/3');
    cy.get('[data-testid=review-rating-input]').select('5');
    cy.get('[data-testid=review-text-input]').type('First review of the test.');
    cy.get('[data-testid=review-submit]').click();
    cy.wait('@postReview');

    cy.get('[data-testid=review-text-input]').type('Second review of the test.');
    cy.get('[data-testid=review-rating-input]').select('3');
    cy.get('[data-testid=review-submit]').click();
    cy.wait('@postReview');

    cy.get('@postReview.all').should('have.length', 2);
    cy.get('@postReview.1').its('request.body.rating').should('eq', 5);
    cy.get('@postReview.2').its('request.body.rating').should('eq', 3);
  });

  it('proves that a request was not made', () => {
    cy.intercept('POST', '/api/reviews').as('postReview');
    cy.visit('/books/3');
    cy.get('[data-testid=review-text-input]').type('Short');
    cy.get('[data-testid=review-submit]').click();

    // The client-side validation message is the proof that the form finished.
    cy.get('[data-testid=review-error]').should('be.visible');
    cy.get('@postReview.all').should('have.length', 0);
  });

  it('matches with a RouteMatcher on the pathname and the query', () => {
    cy.intercept({ method: 'GET', pathname: '/books', query: { q: 'quiet' } }).as('search');
    cy.visit('/books');
    cy.get('[data-testid=search-input]').type('quiet{enter}');
    cy.wait('@search').its('response.statusCode').should('eq', 200);
    cy.get('[data-testid=book-card]').should('have.length', 1);
  });

  it('spies on requests the application makes from the page', () => {
    cy.intercept('GET', '/api/books*').as('books');
    cy.visit('/');
    // Bookshelf renders its catalogue on the server, so ask the page itself to
    // call the REST API; the request goes through the browser, not cy.request.
    cy.window().then((win) => win.fetch('/api/books?page=2'));
    cy.wait('@books').then(({ request, response }) => {
      expect(request.query).to.deep.equal({ page: '2' });
      expect(response?.body).to.have.property('page', 2);
      expect(response?.body.items).to.have.length(6);
    });
  });

  it('aliases an individual request with req.alias', () => {
    cy.intercept('POST', '/api/reviews', (req) => {
      if (req.body.rating === 1) {
        req.alias = 'lowRating';
      }
    });
    cy.visit('/books/3');
    cy.get('[data-testid=review-rating-input]').select('1');
    cy.get('[data-testid=review-text-input]').type('Not for me, sadly.');
    cy.get('[data-testid=review-submit]').click();
    cy.wait('@lowRating').its('response.statusCode').should('eq', 201);
  });
});

cy.wait('@postReview') yields an interception with an id, the request, and, once the server has answered, the response. The request has url, method, headers, body (parsed to an object for a JSON content type), query and resourceType. The response has statusCode, statusMessage, headers and body. response.body is an object only when the response has a Content-Type: application/json header; a response without that header, such as a 304, yields a string you must parse yourself. In TypeScript the types come from cy.wait<ReqBody, ResBody>('@alias') or from Interception<Req, Res>.

Waiting options and several aliases

cy.wait() accepts timeout, requestTimeout and responseTimeout. By default it waits up to requestTimeout for the request to be sent and up to responseTimeout for the response. Passing an array of aliases waits for all of them at once, in any order, and yields the interceptions in the order of the aliases; each alias’s request timeout starts when the cy.wait() starts, so wait on a slow request separately with a larger requestTimeout. The suffixes .all and a 1-based index work with cy.get() but not with cy.wait():

cy.get('@postReview.all');                       // every interception, as an array
cy.get('@postReview.1').its('request.body');     // the first one (indexes start at 1)
cy.get('@postReview');                           // the most recent one
cy.wait(['@books', '@postReview']);              // wait for both, in any order
cy.wait('@postReview', { requestTimeout: 10000 });

That snippet only illustrates the call forms. The calls appear in the specs above and in the command’s documentation, but the snippet was not run as a spec of its own.

Proving that a request was not made

cy.get('@alias.all').should('have.length', 0) passes the moment the array is empty, so it cannot catch a request sent later. The spy example above puts it after an element that proves the form has finished (the validation message). The same technique shows that a cy.request() is invisible to cy.intercept(), which the cy.request section demonstrates.

Aliasing individual requests

A single route can serve several kinds of request: set req.alias inside the handler, as the lowRating test above does. This is the standard way to tell GraphQL operations apart when they all share one URL:

// hasOperationName / aliasQuery as in the official GraphQL recipe
cy.intercept('POST', '/graphql', (req) => {
  aliasQuery(req, 'GetLaunchList');
  aliasMutation(req, 'BookTrips');
});
cy.wait('@gqlGetLaunchListQuery').its('response.body.data.launches').should('exist');

Bookshelf has no GraphQL endpoint, so that snippet is adapted from the documentation and was not run.

Stubbing responses

Passing a response as the last argument turns the route into a stub. The server is never contacted, the application cannot tell the difference, and the response arrives in milliseconds. The documentation’s advice is to mix both styles: a few real end-to-end tests of the critical paths with true server responses, and stubs for the large majority of tests, especially for edge cases that are hard to create with real data (errors, empty lists, slow responses).

StaticResponse property Effect

statusCode

Status code, default 200.

headers

Response headers.

body

A string, an object (sent as JSON) or an ArrayBuffer; used when fixture is omitted.

fixture

Serve a file from the fixtures folder as the body. A suffix such as 'media/intro.mp4,null' chooses the encoding; without it, only image and .zip fixtures are served as raw bytes.

forceNetworkError

Destroy the connection so the request fails the way an unreachable server would. Cannot be combined with body, statusCode or headers.

delay

Minimum extra latency in milliseconds.

throttleKbps

Maximum transfer rate in kilobytes per second.

{ log: false } can be added to a StaticResponse to keep an intercept out of the Command Log, and a string or array passed instead of an object is shorthand for { body }.

describe('cy.intercept as a stub', () => {
  beforeEach(() => {
    cy.request('POST', '/api/test/reset');
    cy.signIn('reader');
  });

  it('answers with a fixture and never reaches the server', () => {
    cy.intercept('POST', '/api/reviews', { statusCode: 201, fixture: 'review-created.json' }).as('postReview');
    cy.visit('/books/3');
    submitReview('Typed text that the stub ignores.');

    cy.wait('@postReview');
    cy.get('[data-testid=review]').last().should('have.attr', 'data-review-id', '901');
    cy.get('[data-testid=review-author]').last().should('have.text', 'reader');

    // The real server still has its three seeded reviews.
    cy.request('/api/reviews').its('body').should('have.length', 3);
  });

  it('rolls back the optimistic row when the server answers 500', () => {
    cy.intercept('POST', '/api/reviews', {
      statusCode: 500,
      body: { error: 'Could not save the review' },
    }).as('postReview');
    cy.visit('/books/3');
    submitReview('This request is stubbed to fail.');

    cy.wait('@postReview');
    cy.get('[data-testid=review-error]').should('have.text', 'Could not save the review');
    // The optimistic row was removed again.
    cy.get('[data-testid=review][data-state=pending]').should('not.exist');
  });

  it('shows the pending state while a delayed response is on its way', () => {
    cy.intercept('POST', '/api/reviews', { delay: 1500, statusCode: 201, fixture: 'review-created.json' }).as('delayed');
    cy.visit('/books/3');
    submitReview('A review that takes its time.');

    cy.get('[data-testid=review]').last().should('have.attr', 'data-state', 'pending');
    cy.wait('@delayed');
    cy.get('[data-testid=review]').last().should('have.attr', 'data-state', 'saved');
  });

  it('survives a network error', () => {
    // Bookshelf does not catch a rejected fetch, so the failure surfaces as an
    // unhandled promise rejection. Tell Cypress that this one is expected.
    cy.on('uncaught:exception', (err) => !err.message.includes('Failed to fetch'));
    cy.intercept('POST', '/api/reviews', { forceNetworkError: true }).as('failed');
    cy.visit('/books/3');
    submitReview('The connection drops mid-request.');

    cy.wait('@failed').should('have.property', 'error');
    // The optimistic row is never settled.
    cy.get('[data-testid=review]').last().should('have.attr', 'data-state', 'pending');
  });

  it('fails once and then succeeds, using times and definition order', () => {
    // Defined first, matches last.
    cy.intercept('POST', '/api/reviews').as('postReview');
    // Defined last, matches first, retires after one request.
    cy.intercept({ method: 'POST', url: '/api/reviews', times: 1 }, { statusCode: 500, body: { error: 'Try again' } }).as('failOnce');

    cy.visit('/books/3');
    submitReview('Fails the first time around.');
    cy.wait('@failOnce').its('response.statusCode').should('eq', 500);
    cy.get('[data-testid=review-error]').should('have.text', 'Try again');

    cy.get('[data-testid=review-submit]').click();
    cy.wait('@postReview').its('response.statusCode').should('eq', 201);
  });

  it('stubs a failed sign-in without touching the credentials', () => {
    cy.intercept('POST', '/api/session', { statusCode: 401, body: { error: 'Locked for maintenance' } }).as('signin');
    cy.clearCookies();
    cy.visit('/signin');
    cy.get('[data-testid=username]').type('reader');
    cy.get('[data-testid=password]').type('anything-at-all');
    cy.get('[data-testid=signin-submit]').click();
    cy.wait('@signin');
    cy.get('[data-testid=signin-error]').should('have.text', 'Locked for maintenance');
  });
});

Some details are visible in these results. A server error and a network failure are different events: statusCode: 500 is a real HTTP response, so fetch resolves with response.ok === false (Bookshelf then rolls its optimistic row back), whereas forceNetworkError: true sends nothing and makes fetch reject. Bookshelf does not catch a rejected fetch, so the rejection reaches the browser as an unhandled promise rejection and Cypress, which treats uncaught application errors as test failures, fails the test unless it is told this one is expected with cy.on('uncaught:exception', ...). Waiting on a route that ended in a network error yields an interception with an error property rather than a response.

The override pattern is worth a second look. A route defined in beforeEach gives most tests a populated catalogue; one test defines the same route again with an empty body and gets it, because the last route defined matches first. To make the first request fail and the next succeed, define the real or fixture route first and the failing route last with times: 1, as the "fails once" test does.

Compressed stub bodies

When a stub declares a content-encoding header, Cypress 16 decodes the body so the browser renders it (gzip, x-gzip, deflate and br; zstd is passed through unchanged). The bytes must really match the declared encoding, or the request errors inside Cypress:

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

Bookshelf does not compress responses, so this snippet was taken from the documentation and was not run.

Dynamic handlers: modify, reply, continue

A function as the last argument receives the intercepted request (req) and runs once per matching request. Anything it changes on req (body, headers, URL, query, method) is merged into the outgoing request and is also seen by later handlers. The handler can return a promise, which is awaited; it must resolve within defaultCommandTimeout.

Call What happens

req.reply(staticResponse), req.reply(body), req.reply(status, body, headers)

Answer from the handler and end the request phase. Later routes never see the request.

req.continue()

Send the request to the server and end the request phase (skips later request handlers).

req.continue((res) => { ... })

The same, and call the function with the real response before the browser gets it. Only one req.continue callback per request.

req.destroy()

Respond with a network error.

req.redirect(location, statusCode?)

Respond with a redirect (default 302).

req.on(event, fn)

Subscribe to before:response, response or after:response.

req.alias, req.responseTimeout, req.followRedirect

Settable properties that control Cypress behavior for this request.

The three response events run in a fixed order, and changes made in the first two reach the browser:

Hook Runs Changes reach the browser

req.on('before:response')

First, before any req.continue() callback

Yes

req.continue(callback)

After before:response

Yes

req.on('response')

After the req.continue() callback, before the browser gets the response

Yes

req.on('after:response')

After the browser has received the response

No

Inside a response callback, res has body, headers, statusCode, statusMessage (empty over HTTP/2 and HTTP/3, which carry no reason phrase), delay and throttleKbps, and the methods res.send() (end the response phase, optionally merging a StaticResponse), res.setDelay(ms) and res.setThrottle(kbps).

describe('cy.intercept with a routeHandler', () => {
  beforeEach(() => {
    cy.request('POST', '/api/test/reset');
    cy.signIn('reader');
  });

  it('changes the outgoing request body', () => {
    cy.intercept('POST', '/api/reviews', (req) => {
      req.body.text = `${req.body.text} (edited by the test)`;
    }).as('postReview');
    cy.visit('/books/3');
    submitReview('Original wording of the review.');

    cy.wait('@postReview').then(({ request, response }) => {
      expect(request.body.text).to.eq('Original wording of the review. (edited by the test)');
      expect(response?.body.text).to.eq('Original wording of the review. (edited by the test)');
    });
  });

  it('adds a header to the outgoing request', () => {
    cy.intercept('POST', '/api/reviews', (req) => {
      req.headers['x-test-run'] = 'network-page';
    }).as('postReview');
    cy.visit('/books/3');
    submitReview('Header added by an intercept.');
    cy.wait('@postReview').its('request.headers').should('have.property', 'x-test-run', 'network-page');
  });

  it('decides the response at request time with req.reply', () => {
    cy.intercept('POST', '/api/reviews', (req) => {
      if (req.body.rating === 1) {
        req.reply({ statusCode: 422, body: { error: 'One star is not allowed here' } });
      }
      // Otherwise do nothing and let the request go to the server.
    }).as('postReview');
    cy.visit('/books/3');
    submitReview('A deliberately harsh review.', '1');
    cy.wait('@postReview').its('response.statusCode').should('eq', 422);
    cy.get('[data-testid=review-error]').should('have.text', 'One star is not allowed here');
  });

  it('keeps the real response and changes one field with req.continue', () => {
    cy.intercept('POST', '/api/reviews', (req) => {
      req.continue((res) => {
        res.body.author = 'Somebody Else';
      });
    }).as('postReview');
    cy.visit('/books/3');
    submitReview('The server answers, the test edits.');
    cy.wait('@postReview').its('response.statusCode').should('eq', 201);
    cy.get('[data-testid=review-author]').last().should('have.text', 'Somebody Else');
  });

  it('reads the real response in a before:response listener', () => {
    cy.intercept('GET', '/api/books*', (req) => {
      req.on('before:response', (res) => {
        res.headers['x-seen-by-cypress'] = 'yes';
      });
    }).as('books');
    cy.visit('/');
    cy.window().then((win) => win.fetch('/api/books').then((r) => r.headers.get('x-seen-by-cypress'))).should('eq', 'yes');
    cy.wait('@books');
  });

  it('stubs a REST list that the page requests with fetch', () => {
    cy.intercept('GET', '/api/books*', { fixture: 'books-page.json' }).as('books');
    cy.visit('/');
    cy.window()
      .then((win) => win.fetch('/api/books').then((r) => r.json()))
      .its('items')
      .should('have.length', 2);
    cy.wait('@books');
  });

  it('rewrites the HTML of a document request', () => {
    cy.intercept({ method: 'GET', pathname: '/books/3', resourceType: 'document' }, (req) => {
      req.continue((res) => {
        res.body = res.body.replace('</body>', '<p data-testid="banner">Injected by the test</p></body>');
      });
    });
    cy.visit('/books/3');
    cy.get('[data-testid=banner]').should('have.text', 'Injected by the test');
  });

  it('marks every middleware request with a header', () => {
    cy.intercept({ url: '/api/**', middleware: true }, (req) => {
      req.headers['x-from-middleware'] = '1';
    });
    cy.intercept('POST', '/api/reviews').as('postReview');
    cy.visit('/books/3');
    submitReview('Middleware runs before the others.');
    cy.wait('@postReview').its('request.headers').should('have.property', 'x-from-middleware', '1');
  });
});

The last two handler tests show what interception can do beyond JSON: resourceType: 'document' lets a route edit the HTML of a page navigation, and middleware: true runs a route before every other route, which is where cross-cutting behaviour belongs (adding an auth header, removing cache headers, throttling). A middleware route should not call reply() or continue(), or it ends the chain for everyone behind it.

Throttling every API response is a typical use. This snippet is from the documentation and was not run, because Bookshelf’s responses are too small for a transfer rate to be observable:

beforeEach(() => {
  cy.intercept({ url: 'http://localhost:3001/**', middleware: true }, (req) => {
    req.on('response', (res) => {
      // 125 kilobytes per second, about 1 Mbps: a mobile 3G connection
      res.setThrottle(125);
    });
  });
});

Keeping intercepts cheap

A catch-all such as cy.intercept('*') matches every request the page makes, including images, fonts and third-party scripts, and a route with a handler holds each request until the handler finishes. Scope routes to the endpoint the test needs. Third-party services that tests do not need (analytics, error monitoring) belong in blockHosts, and Cypress answers those requests with a 503:

import { defineConfig } from 'cypress';

export default defineConfig({
  blockHosts: ['*.sentry.io'],
});

That configuration comes from the documentation and was not run. A CORS preflight (OPTIONS) for a cross-origin request is answered by Cypress automatically whenever a route matches the request’s URL, so a stub for another origin needs no CORS headers; add a route that matches the OPTIONS method to test a preflight that fails.

Native network interception in Cypress 16

Before Cypress 16, every browser sent its traffic through a Cypress HTTP/1.1 proxy. That is how Cypress observed and stubbed requests, but it meant HTTP/2 and HTTP/3 connections were downgraded to HTTP/1.1 during a run, and Cypress terminated TLS and issued its own certificate for the origin under test. Cypress 16.0.0 (1 September 2026) changed this for Chrome, Chromium and Edge: the browser now connects to your server itself, negotiates whatever protocol the server supports, validates the real certificate, and Cypress intercepts the traffic inside the browser. The cy.intercept() API is unchanged and most suites need no edits.

Browser forceHttp1 false (default) forceHttp1: true

Chrome, Chromium, Edge

Native browser network

Legacy network path

Firefox, WebKit

Legacy network path

Legacy network path

Electron

Legacy network path

Legacy network path

A suite that runs in more than one browser therefore sees both behaviours in the same run. The visible differences, all of which apply only to Chrome, Chromium and Edge:

Behaviour on the native path What to do

content-encoding, content-length and transfer-encoding are not on intercepted response headers; the body is already decoded

Assert on the decoded body, not on the compression headers.

req.httpVersion is undefined and res.httpVersion is null; the protocol is negotiated with the server after the intercept

Assert on request properties that describe your application.

A response the browser rejects before delivering it never reaches Cypress; interception.response is undefined

Assert on the error state your application renders.

Responses answered inside Cypress (stubs, cy.visit() documents on http origins, bodies edited by a handler) are not stored in the browser HTTP cache

Expect the intercept to see the request again on a second navigation.

A stubbed body is decoded from its declared content-encoding

Make sure a declared encoding matches the bytes.

Compressed documents from insecure origins load

Update tests that expected the visit itself to fail.

A revalidated response reports 200, not 304

Use cy.request() to assert on caching behaviour.

responseTimeout does not apply to response handlers (a fixed 30 second limit does)

Bound the wait with a timeout on cy.wait().

req.headers['content-length'] is not reported

Assert on the body you set.

The application’s own certificate is used; modified requests and stubbed responses appear in the browser’s network panel

Nothing — this is the improvement.

Seeing the difference

The network-path.cy.ts spec asserts the property that differs most clearly. It chooses its expectation from the browser and from the configuration, and prints what it saw through a cy.task() so the run can be compared:

// Chrome, Chromium and Edge use the native browser network in Cypress 16,
// unless forceHttp1 is true. Firefox, WebKit and Electron always use the legacy path.
const usesNativeBrowserNetwork =
  Cypress.isBrowser({ family: 'chromium', name: '!electron' }) && !Cypress.config('forceHttp1');

describe('which network path is under test', () => {
  it('reports what the browser exposes', () => {
    cy.log(`browser=${Cypress.browser.name} forceHttp1=${String(Cypress.config('forceHttp1'))} native=${usesNativeBrowserNetwork}`);
    cy.intercept('POST', '/api/session').as('signin');
    cy.visit('/signin');
    cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
      cy.get('[data-testid=username]').type('reader');
      cy.get('[data-testid=password]').type(READER_PASSWORD, { log: false });
    });
    cy.get('[data-testid=signin-submit]').click();

    cy.wait('@signin').then(({ request }) => {
      cy.task('print', {
        browser: Cypress.browser.name,
        native: usesNativeBrowserNetwork,
        httpVersion: request.httpVersion ?? null,
        hasContentLength: 'content-length' in request.headers,
      });
      if (usesNativeBrowserNetwork) {
        expect(request.httpVersion, 'httpVersion on the native path').to.be.undefined;
        expect(request.headers).to.not.have.property('content-length');
      } else {
        expect(request.httpVersion, 'httpVersion on the legacy path').to.eq('1.1');
        expect(request.headers).to.have.property('content-length');
      }
      // True on both paths: the body the application sent.
      expect(request.body).to.have.property('username', 'reader');
    });
    cy.location('pathname').should('eq', '/account');
  });

  it('shows the modified request headers on both paths', () => {
    cy.intercept('GET', '/api/session', (req) => {
      req.headers['x-network-path'] = 'checked';
    }).as('session');
    cy.visit('/');
    cy.window().then((win) => win.fetch('/api/session'));
    cy.wait('@session').its('request.headers').should('have.property', 'x-network-path', 'checked');
  });
});

Running it four ways on the same machine and the same Bookshelf produced these lines (the Firefox run used the Docker image):

npx cypress run --browser electron --spec cypress/e2e/network-path.cy.ts
{"browser":"electron","native":false,"httpVersion":"1.1","hasContentLength":true}

npx cypress run --browser chrome --spec cypress/e2e/network-path.cy.ts
{"browser":"chrome","native":true,"httpVersion":null,"hasContentLength":false}

npx cypress run --browser chrome --config forceHttp1=true --spec cypress/e2e/network-path.cy.ts
{"browser":"chrome","native":false,"httpVersion":"1.1","hasContentLength":true}

docker run --rm -e BASE_URL=http://host.docker.internal:3924 -e CYPRESS_READER_PASSWORD="$BOOKSHELF_READER_PASSWORD" \
  -v "$PWD":/e2e -v "$PWD/../cypress/node_modules":/e2e/node_modules:ro -w /e2e --entrypoint /bin/sh \
  cypress/included:16.2.0 -c "cypress run --browser firefox --spec cypress/e2e/network-path.cy.ts"
{"browser":"firefox","native":false,"httpVersion":"1.1","hasContentLength":true}

Only Chrome with the default setting takes the native path; Electron, Firefox and Chrome with forceHttp1 all report '1.1' and a content-length header. Bookshelf is a plain HTTP/1.1 server, so HTTP/2 and HTTP/3 behaviour, content-encoding and the 304 case were not exercised. Both tests passed on every browser, including the second, which shows that a header added in a handler is visible in request.headers on both paths.

Writing tests that pass on both paths

Prefer assertions that describe your application — the response body, the rendered page, the request the application sent — over transport metadata. When an assertion must differ, gate it with Cypress.isBrowser() and exclude Electron by name, because Electron is Chromium-based and a family: 'chromium' filter alone would put it on the wrong side:

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

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

The network-path spec above adds !Cypress.config('forceHttp1') to the same gate, because forceHttp1 moves Chrome to the legacy path.

forceHttp1 is an escape hatch, not a setting

forceHttp1: true sends every browser through the legacy path and restores the Cypress 15 behaviour. It was introduced and deprecated in the same release: Cypress prints a warning when it is set, and it will be removed once the native browser network is supported long term in both Chrome and Firefox. Changing it restarts the Cypress server, and it cannot be set per test or per spec.

import { defineConfig } from 'cypress';

export default defineConfig({
  forceHttp1: true,
});

The --config forceHttp1=true run above exercised this option from the command line. Use it to buy time to migrate or while an issue you have filed is fixed; do not use it only to avoid updating Cypress.isBrowser() gates.

TLS certificates and the disk cache (16.1.0)

Cypress starts every Chromium-based browser with --ignore-certificate-errors, so origins with self-signed or private-CA certificates load without prompts. Chromium, however, does not write responses from connections whose certificate errors were merely ignored to its disk cache, so an HTTPS application with a self-signed certificate re-downloaded every asset on each cy.visit(). Cypress 16.1.0 added trustedCertificates: certificates listed there have their SPKI fingerprints passed to --ignore-certificate-errors-spki-list (on the native path only), so Chromium treats those origins as trusted and caches their assets as in production. Declare either the leaf certificate or, when the server presents a chain, the CA or the leaf.

import { defineConfig } from 'cypress';

export default defineConfig({
  trustedCertificates: [{ filePath: 'certs/dev-server.crt' }],
});

Bookshelf serves plain HTTP, so trustedCertificates was not run.

cy.fixture(): data kept out of the spec

cy.fixture(filePath, encoding?, options?) loads a file from the fixturesFolder (default cypress/fixtures) and yields its contents. The file extension is optional: Cypress looks for the exact name first and then tries .json, .js, .html, .txt, .csv, the image types and .zip in that order. Fixtures can be nested in folders (cy.fixture('users/admin.json')).

File type, or encoding argument Yields

.json

The parsed JSON.

.js

The value of the file, evaluated as a single JavaScript expression (not loaded as a module).

.png, .jpg, .jpeg, .gif, .tif, .tiff, .zip

A base64 string.

.html, .txt, .csv and any other extension

A UTF-8 string.

An encoding such as 'utf8', 'base64' or 'hex'

The raw contents in that encoding, whatever the extension (so cy.fixture('users.json', 'utf8') yields a string).

null

A Cypress.Buffer.

The command’s timeout option defaults to responseTimeout. A few behaviours matter in practice. The file is read once per path and encoding and then served from a cache, and each call yields a copy, so editing the object in one test cannot affect another. The command does not retry and is not logged. The fixture’s JSON or JavaScript syntax is validated, so a typo fails with an error that names the file. And cy.fixture() is meant for known, fixed data: use cy.readFile() for a file that changes while the run is in progress. Aliases are cleared between tests, so load a fixture in beforeEach with .as() and read it back with cy.get('@alias'), which also works with arrow functions, unlike the this context.

describe('cy.fixture', () => {
  it('yields parsed JSON and a raw string when an encoding is given', () => {
    cy.fixture('review-created').its('author').should('eq', 'reader');
    cy.fixture('review-created.json', 'utf8').should('be.a', 'string').and('include', '"id": 901');
  });

  it('yields a copy, so changing it does not change the next call', () => {
    cy.fixture<Review>('review-created').then((review) => {
      review.rating = 1;
    });
    cy.fixture<Review>('review-created').its('rating').should('eq', 5);
  });

  it('can be edited before it becomes a stub', () => {
    cy.request('POST', '/api/test/reset');
    cy.signIn('reader');
    cy.fixture<Review>('review-created').then((review) => {
      review.text = 'Edited before stubbing.';
      cy.intercept('POST', '/api/reviews', { statusCode: 201, body: review }).as('postReview');
    });
    cy.visit('/books/3');
    cy.get('[data-testid=review-rating-input]').select('5');
    cy.get('[data-testid=review-text-input]').type('Whatever the user types.');
    cy.get('[data-testid=review-submit]').click();
    cy.wait('@postReview').its('response.body.text').should('eq', 'Edited before stubbing.');
    cy.get('[data-testid=review-text]').last().should('have.text', 'Whatever the user types.');
  });
});

The fixture files used by the stubs are plain JSON:

{
  "id": 901,
  "bookId": 3,
  "author": "reader",
  "rating": 5,
  "text": "A stubbed review that never reached the server."
}

That is review-created.json, the file the stub tests above load. A fixture can also be served straight from cy.intercept() with { fixture: 'review-created.json' }, which is what most stubs do. Binary fixtures such as .mp4 or .pdf are read as UTF-8 unless you add ,null to the name. cy.fixture() reads the whole file into memory and sends it to the browser over one internal WebSocket message, so avoid files larger than a few megabytes; upload a large file with .selectFile('cypress/fixtures/large.zip') and a path, or inspect it with cy.task(). To import a JSON fixture directly (for example to generate one test per record, which a loop inside a cy.fixture() callback cannot do), use a normal import.

cy.request(): talking to the server without a page

cy.request() sends an HTTP request from the Cypress server process in Node.js. Because no browser is involved, CORS and the same-origin policy do not apply, the request never shows up in the browser’s DevTools network tab, and cy.intercept() cannot see it. Cookies still work: before sending, Cypress attaches the browser’s cookies for the URL, and it writes any Set-Cookie response headers back into the browser. That shared cookie jar is what makes hybrid tests natural — log in or seed data over HTTP, then drive the page, or the other way round. Cypress does not verify the server’s TLS certificate for cy.request(), so it also cannot catch a certificate problem.

Call forms: cy.request(url), cy.request(url, body), cy.request(method, url), cy.request(method, url, body) and cy.request(options). A relative URL resolves against the cy.origin() origin, then baseUrl, then the origin of the last visited page. Options:

Option Default Meaning

url, method

null, GET

Target and HTTP method (including QUERY since 15.20.0 and the WebDAV and extension methods listed in the docs).

body

none

String, object, boolean, FormData or Blob. An object or boolean is JSON-serialized with Content-Type: application/json; a string is sent as is; FormData is sent as multipart/form-data.

form

false

Send body URL-encoded as a form.

qs

null

Query parameters, merged with any query string already in the URL.

headers, auth

null

Extra headers; auth takes { username, password } (HTTP Basic) or { bearer }.

failOnStatusCode

true

Fail the test when the status is not 2xx or 3xx. Set false to assert on an error response.

followRedirect

true

Follow redirects (a redirected POST, PUT or DELETE is followed as a GET unless the status is 307). false yields the redirect with redirectedToUrl.

retryOnStatusCodeFailure, retryOnNetworkFailure

false, true

Retry up to 4 times on a bad status (requires failOnStatusCode: true) or on transient network errors such as ECONNREFUSED.

encoding, gzip

utf8, true

Encoding used for the response body; accept gzip.

timeout, log

responseTimeout, true

Per-request time limit and Command Log entry (set log: false to keep credentials out of the log).

It yields an object with status, statusText, isOkStatusCode, body, headers, duration, requestHeaders, allRequestResponses, and, when relevant, redirects and redirectedToUrl. body is parsed into an object when the response’s Content-Type ends in json (or when the request body was an object and the response is valid JSON); for a FormData or Blob request it is an ArrayBuffer you decode yourself. The command does not retry its assertions, and a request that gets no response fails.

describe('cy.request', () => {
  beforeEach(() => {
    cy.request('POST', '/api/test/reset');
  });

  it('reads the catalogue', () => {
    cy.request('/api/books?q=quiet').then((response) => {
      expect(response.status).to.eq(200);
      expect(response.headers['content-type']).to.include('application/json');
      expect(response.body.items).to.have.length(1);
      expect(response.duration).to.be.lessThan(1000);
    });
  });

  it('asserts on an error status with failOnStatusCode: false', () => {
    cy.request({ url: '/api/books/9999', failOnStatusCode: false }).then((response) => {
      expect(response.status).to.eq(404);
      expect(response.isOkStatusCode).to.be.false;
      expect(response.body).to.deep.equal({ error: 'not found' });
    });
  });

  it('requires a session to write a review (401) and validates the body (422)', () => {
    cy.request({
      method: 'POST',
      url: '/api/reviews',
      body: { bookId: 3, rating: 5, text: 'Signed-out reviewer.' },
      failOnStatusCode: false,
    })
      .its('status')
      .should('eq', 401);

    cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
      cy.request({
        method: 'POST',
        url: '/api/session',
        body: { username: 'reader', password: READER_PASSWORD },
        log: false, // keep the password out of the Command Log
      })
        .its('status')
        .should('eq', 200);
    });

    cy.request({
      method: 'POST',
      url: '/api/reviews',
      body: { bookId: 3, rating: 9, text: 'x' },
      failOnStatusCode: false,
    }).then((response) => {
      expect(response.status).to.eq(422);
      expect(response.body.errors).to.have.keys('rating', 'text');
    });
  });

  it('walks a create, read and verify flow, with the cookie shared with the browser', () => {
    cy.signIn('reader');
    cy.request('POST', '/api/reviews', { bookId: 3, rating: 4, text: 'Created over HTTP.' })
      .its('body')
      .then((review: Review) => {
        expect(review.author).to.eq('reader');
        cy.request(`/api/reviews?bookId=${review.bookId}`)
          .its('body')
          .should('deep.include', review);
      });
    cy.getCookie('bookshelf_session').should('exist');
  });

  it('mixes API setup with UI verification', () => {
    cy.signIn('reader');
    cy.request('POST', '/api/reviews', { bookId: 3, rating: 5, text: 'Seeded through the API.' });
    cy.visit('/books/3');
    cy.contains('[data-testid=review-text]', 'Seeded through the API.').should('be.visible');
  });

  it('is not seen by cy.intercept', () => {
    cy.intercept('GET', '/api/books*').as('books');
    cy.request('/api/books');
    cy.get('@books.all').should('have.length', 0);
  });

  it('follows redirects unless told not to', () => {
    cy.request({ url: '/account', followRedirect: false }).then((response) => {
      expect(response.status).to.eq(302);
      expect(response.redirectedToUrl).to.match(/\/signin\?next=\/account$/);
    });
    cy.request('/account').then((response) => {
      expect(response.redirects?.[0]).to.match(/^302: .*\/signin\?next=\/account$/);
      expect(response.status).to.eq(200);
    });
  });
});

The READER_PASSWORD read with cy.env() and the log: false on the sign-in request keep the password out of both the spec file and the Command Log.

API testing patterns

Cypress treats the API as a first-class target: the same runner, the same configuration and the same CI job as the UI tests, and the Command Log shows every request with its method, status and URL. The patterns that come up most:

  • Setup and teardown over HTTP. Create the user, the order or the review with cy.request() in beforeEach, as the signIn command and the "mixes API setup with UI verification" test do. It takes milliseconds and does not re-test the form.

  • Error paths. failOnStatusCode: false returns 401, 404 and 422 responses so the test can assert on status and body; they are cheap to trigger over HTTP and awkward through a form.

  • Contract checks. Assert on the shape of a response (keys, types, enums) as well as on values.

  • Hybrid tests. Log in over HTTP and continue in the UI, or log in through the UI and verify over HTTP with the session cookie carried along.

  • Uploads. Pass a FormData body; Cypress sets the multipart boundary itself. Do not combine it with form: true.

  • Polling a background job. Call cy.request() from a function that calls itself with an attempts counter, so a job that never finishes fails the test instead of looping.

A file upload with FormData looks like this. The snippet is adapted from the documentation and was not run, because Bookshelf has no upload route:

cy.fixture('avatar.png').then((image) => {
  const formData = new FormData();
  formData.append('file', Cypress.Blob.base64StringToBlob(image, 'image/png'), 'avatar.png');
  formData.append('visibility', 'public');

  cy.request({ method: 'POST', url: '/users/me/avatar', body: formData })
    .its('status')
    .should('eq', 201);
});

cy.spy() and cy.stub(): functions, not requests

Cypress bundles Sinon.js for spies and stubs and Sinon-Chai for the assertions (calledOnce, calledWith, returned, thrown, …​), so every Sinon method is available. cy.spy(object, 'method') wraps a method and records its calls, arguments, return values and context while the original keeps running. cy.stub(object, 'method') replaces the method and lets the test decide what it does, with .returns(), .resolves(), .rejects(), .callsFake(), .callThrough() and .withArgs(); cy.stub() with no arguments creates an anonymous stub, for example to pass as a callback in a unit test. Both are plain functions that return the spy or stub synchronously, not chainable commands, so they are not retried and have no timeout.

Useful details:

  • .as('name') names a spy or stub, so it shows in the Command Log and can be read back with cy.get('@name'); .withArgs(...) stubs can be aliased individually. .log(false) silences the Command Log entry for a function that is called often.

  • Spies, stubs and clocks live in a sandbox that Cypress resets and restores between tests, so nothing needs manual cleanup.

  • To replace a built-in such as window.fetch or window.prompt, do it after the page is created but before the application’s scripts run: inside the onBeforeLoad(win) option of cy.visit(). In a component test, stub before mounting.

  • A spy is for checking the contract between functions without changing their effects; a stub is for functions with side effects you want to control — navigation, dialogs, OAuth, failure paths.

describe('cy.spy and cy.stub on the application window', () => {
  beforeEach(() => {
    cy.request('POST', '/api/test/reset');
    cy.signIn('reader');
  });

  it('spies on window.fetch without changing it', () => {
    cy.visit('/books/3', {
      onBeforeLoad(win) {
        cy.spy(win, 'fetch').as('fetch');
      },
    });
    cy.get('[data-testid=review-rating-input]').select('4');
    cy.get('[data-testid=review-text-input]').type('Spied on, then saved.');
    cy.get('[data-testid=review-submit]').click();

    cy.get('@fetch').should('have.been.calledOnce');
    cy.get('@fetch').should('have.been.calledWith', '/api/reviews');
    cy.get('[data-testid=review]').last().should('have.attr', 'data-state', 'saved');
  });

  it('stubs window.fetch to answer without any network request', () => {
    cy.intercept('POST', '/api/reviews').as('postReview');
    cy.visit('/books/3', {
      onBeforeLoad(win) {
        cy.stub(win, 'fetch')
          .callsFake(() =>
            Promise.resolve(
              new win.Response(JSON.stringify({ id: 77, author: 'stub', rating: 4, text: 'x' }), {
                status: 201,
                headers: { 'content-type': 'application/json' },
              }),
            ),
          )
          .as('fetch');
      },
    });
    cy.get('[data-testid=review-rating-input]').select('4');
    cy.get('[data-testid=review-text-input]').type('Never leaves the page.');
    cy.get('[data-testid=review-submit]').click();

    cy.get('[data-testid=review-author]').last().should('have.text', 'stub');
    cy.get('@fetch').should('have.been.calledOnce');
    cy.get('@postReview.all').should('have.length', 0);
  });

  it('stubs a method on an object and chooses its answer per argument', () => {
    const calculator = { add: (a: number, b: number) => a + b };
    const add = cy.stub(calculator, 'add');
    add.withArgs(1, 2).returns(42);
    add.callThrough();

    expect(calculator.add(1, 2)).to.eq(42);
    expect(calculator.add(2, 2)).to.eq(4);
    expect(add).to.have.been.calledTwice;
    expect(add).to.have.been.calledWithExactly(1, 2);
  });

  it('stubs window.prompt before the app loads', () => {
    cy.visit('/books/3', {
      onBeforeLoad(win) {
        cy.stub(win, 'prompt').returns('typed by the stub');
      },
    });
    cy.window().then((win) => {
      expect(win.prompt('Your name?')).to.eq('typed by the stub');
    });
    cy.window().its('prompt').should('have.been.calledWith', 'Your name?');
  });
});

Notice how the two tools relate to cy.intercept(). The stubbed fetch test shows the application receiving a response without any request: the postReview intercept registered in the same test records zero requests. That is useful for testing how the page reacts to an arbitrary result, but a route stub (cy.intercept()) is usually closer to production because the real fetch code still runs. Prefer cy.intercept() for HTTP and reserve cy.stub() for browser APIs and application functions.

cy.clock() and cy.tick(): fake time

cy.clock() overrides the page’s native time functions so a test can move time forward synchronously. By default it controls setTimeout/clearTimeout, setInterval/clearInterval, requestAnimationFrame/cancelAnimationFrame, requestIdleCallback, Date, performance (for example performance.now()) and Intl.DateTimeFormat, which formats the faked time when no date is passed. The clock starts at the Unix epoch (timestamp 0) unless you pass a now value, so new Date() in the page returns 1 January 1970 by default.

Call Meaning

cy.clock()

Fake everything in the default set, starting at the epoch.

cy.clock(now)

Start at a timestamp or Date. 0 or null starts at the epoch, which lets you pass functionNames.

cy.clock(now, ['Date', 'setTimeout'])

Fake only the listed functions. Valid names: setTimeout, clearTimeout, setInterval, clearInterval, Date, requestAnimationFrame, cancelAnimationFrame, requestIdleCallback, cancelIdleCallback, performance, Intl.

cy.clock(..., { log: false })

Keep the command out of the Command Log.

cy.tick(ms)

Move the clock forward by ms milliseconds and run every timer due in that range. Requires a prior cy.clock().

clock.tick(ms), clock.restore(), clock.setSystemTime(now)

Methods on the yielded clock (cy.clock().then((clock) => ...), or this.clock in a function () {} test). setSystemTime changes the current time without firing timers or changing the time left on them (since 10.7.0).

Rules to remember:

  • Call cy.clock() before cy.visit(): Cypress binds the fake clock to the page on window load, before the application’s code runs, so a setTimeout made at load time can still be ticked. After a reload or navigation each new page gets a fresh clock starting again at the original now; time advanced on the previous page and its timers do not carry over.

  • Calling cy.clock() again in the same test yields the existing clock and ignores its arguments.

  • clock.restore() returns the native functions to the window, and Cypress does it between tests automatically. Timers registered while the fake clock was active are discarded by restore(); new ones run in real time.

  • The clock applies to the top window only, not to iframe content, and CSS animations and transitions run on the browser’s own clock, so cy.tick() does not move them.

  • Overriding requestAnimationFrame means JavaScript animations only advance on cy.tick(); to freeze the date and control timeouts while animation frames keep running, list only ['Date', 'setTimeout', 'clearTimeout'].

  • Typical uses: debounced inputs (assert that nothing is sent before the delay, tick, assert that the request goes out), inactivity timeouts and session-expiry warnings, polling intervals and midnight or month rollovers.

Bookshelf has no client-side timers and renders no dates, so the specs register a timer on the page’s window themselves to show the mechanics:

describe('cy.clock and cy.tick', () => {
  beforeEach(() => {
    cy.request('POST', '/api/test/reset');
    cy.signIn('reader');
  });

  it('freezes Date and runs a timer the test registers', () => {
    cy.clock(Date.UTC(2026, 0, 15, 12, 0, 0), ['Date', 'setTimeout']);
    cy.visit('/');
    cy.window().then((win) => {
      expect(new win.Date().toISOString()).to.eq('2026-01-15T12:00:00.000Z');
      win.setTimeout(() => {
        win.document.title = 'ticked';
      }, 5 * 60 * 1000);
    });
    cy.title().should('not.eq', 'ticked');
    cy.tick(5 * 60 * 1000);
    cy.title().should('eq', 'ticked');
    cy.window().then((win) => {
      expect(new win.Date().toISOString()).to.eq('2026-01-15T12:05:00.000Z');
    });
  });

  it('does not stop cy.wait, because the network is not part of the page clock', () => {
    cy.clock();
    cy.intercept('POST', '/api/reviews').as('postReview');
    cy.visit('/books/3');
    cy.get('[data-testid=review-rating-input]').select('5');
    cy.get('[data-testid=review-text-input]').type('Written on a frozen clock.');
    cy.get('[data-testid=review-submit]').click();
    cy.wait('@postReview').its('response.statusCode').should('eq', 201);
  });

  it('moves the clock without firing timers using setSystemTime', () => {
    cy.clock(0);
    cy.visit('/');
    cy.window().then((win) => {
      win.setTimeout(() => {
        win.document.title = 'fired';
      }, 1000);
    });
    cy.clock().then((clock) => {
      clock.setSystemTime(60 * 60 * 1000);
    });
    cy.title().should('not.eq', 'fired');
    cy.tick(1000);
    cy.title().should('eq', 'fired');
  });

  it('restores real timers with clock.restore()', () => {
    cy.clock();
    cy.visit('/');
    cy.clock().then((clock) => clock.restore());
    cy.window().then((win) => {
      expect(new win.Date().getFullYear()).to.be.greaterThan(2000);
    });
  });
});

The second test makes a point that is easy to miss: a fake page clock does not freeze cy.wait('@postReview') or the server. The server’s 100 millisecond review delay runs in real time, and Cypress’s own timeouts are not faked. When testing a debounce with a request, the usual pattern combines both tools: alias the request with cy.intercept(), cy.tick() past the debounce, then cy.wait() for the request. This snippet is from the documentation and was not run, because Bookshelf has no debounced search:

cy.intercept('GET', '/api/search*').as('search');
cy.clock();
cy.visit('/products');
cy.get('#search').type('shoes');

// the debounce hasn't elapsed, so no request has been sent
cy.get('@search.all').should('have.length', 0);

cy.tick(300);
cy.wait('@search');

Common pitfalls

Symptom Cause and fix

cy.as() fails with "This word is reserved"

The names test, runnable, timeout, slow, skip and inspect are reserved. Aliasing the delayed route slow fails with exactly this error, which is why the stub spec above calls it delayed.

An intercept never fires

The route was defined after the request, the glob did not cover the query string ('/api/users' versus '/api/users*'), the method differs, or the response came from the browser cache. The Routes panel shows a - for a route that matched nothing.

? in a glob matches the wrong URLs

? is a wildcard; escape it with a backslash (two in a JavaScript string) or match pathname and query separately.

cy.intercept() does not see a cy.request()

By design; cy.request() goes from Node.js. Use the .all assertion on an aliased route to prove it, as the request spec does.

cy.wait('@alias.all') fails

Suffixes are supported by cy.get() only.

A test fails with "Failed to fetch" from the application

forceNetworkError makes fetch reject; handle it in the application or declare the uncaught exception expected in the test.

A response body is a string, not an object

The response has no application/json content type (a 304 or a 301, for example). Parse it with JSON.parse().

cy.fixture() returns the wrong type

The default depends on the extension (parsed JSON, base64 images, UTF-8 text); pass an encoding or null to choose.

The same test passes in Chrome and fails in Firefox

Compare the native and legacy paths: httpVersion, content-length and compression headers differ. See the gating example above.

cy.clock() does nothing for an animation

CSS animations ignore it; only JavaScript timers and frames are faked, and only in the top window.

A mocked list still shows stale data after a stub

Routes only catch requests sent after they are defined, and a server-rendered page makes no fetch at all: stub the document request (resourceType: 'document') or seed the data with /api/test/seed.

Behaviour does not depend on headed or headless mode: the specs in this page ran headless with cypress run, and interception, stubs and clocks work the same way in cypress open, where the Routes panel and the Command Log show each route, its alias and how many requests it matched. The only environment dependency is the browser: Chrome, Chromium and Edge use the native path, so CI jobs that run several browsers should treat the gating advice above as part of the suite design. In CI, point baseUrl at the server through BASE_URL and pass secrets as CYPRESS_* variables, as shown at the top.

What changed recently

  • Cypress 16.2.0 (13 October 2026) is the baseline for this page; it also fixed a 16.0.0 regression where a configured clientCertificates entry was not presented for browser-issued requests in Chrome, Chromium and Edge.

  • Cypress 16.1.0 (15 September 2026) added trustedCertificates for the native browser network path, so assets from a self-signed development server are cached on disk, and fixed a 16.0.0 regression where blockHosts was not enforced in Chrome, Chromium and Edge.

  • Cypress 16.0.0 (1 September 2026) introduced native network interception for Chrome, Chromium and Edge, together with forceHttp1 (deprecated when introduced) as the way back to the legacy path. It also removed Cypress.env() in favour of cy.env() and Cypress.expose() and removed cy.exec(); the specs on this page use cy.env().

  • Cypress 15.20.0 added support for the HTTP QUERY method in cy.request() and cy.intercept().

  • Cypress 14.0.0 deprecated the resourceType property of cy.intercept().

  • Cypress 12.2.0 added resourceType to the request object and the route matcher; 7.6.0 added req.query.

  • Cypress 10.7.0 added clock.setSystemTime().

  • Cypress 7.4.0 added FormData and Blob request bodies to cy.request(); 7.0.0 reversed the order in which routes run (the last defined first), added the request events and the middleware option, and renamed res.delay() and res.throttle() to res.setDelay() and res.setThrottle().

  • Cypress 6.0.0 renamed cy.route2() to cy.intercept() and made the feature the default; the experimental form appeared in 5.1.0.