Skip to content

Blog / Cypress

Cypress

Cypress vs Playwright in 2026: A Factual Comparison with the Same Test in Both

A dated, checkable comparison of Cypress and Playwright, built around one test written in both, with where each tool genuinely wins.

October 23, 2026 · Blendra Team · 13 min read

Most Cypress vs Playwright articles repeat adoption percentages and speed benchmarks that you can't check. This one does something narrower and more useful: it writes the same login-and-checkout test in both tools, runs it, and explains every difference by pointing at the official docs. Every claim is dated as of October 2026, against Cypress 16.2 and Playwright 1.64. Tools move fast, so follow the links before you decide.

All the code is in one small repo with a demo shop. It needs no third-party sites. Both suites passed on Linux with Cypress 16.2.0 (Chrome and Electron) and Playwright 1.61 (Chromium).

Cypress vs Playwright: the verdict table (as of October 2026)

Cypress 16.2 Playwright 1.64
Where your test code runs Inside the browser, next to your app, with a Node server beside it In Node worker processes that drive the browser from outside
Languages JavaScript and TypeScript only TypeScript/JavaScript, Python, Java, .NET
Browsers Chrome family, Firefox 140+, Electron (deprecated); WebKit is experimental Bundled Chromium, Firefox and WebKit builds; branded Chrome and Edge through channels
Second tab in one test Not built in; the @cypress/puppeteer plugin (public beta, Chromium only) Built in: every tab is a Page
Second origin in one test Yes, inside cy.origin() Yes, nothing special needed
iframes Works with limits; no "switch into iframe" command frameLocator()
Parallel runs Across machines with --record --parallel (Cypress Cloud) Local workers by default; --shard across machines, no service needed
Debugging Interactive runner with time travel; Test Replay in Cypress Cloud UI mode and trace viewer, both local and free
Component testing Mature: React, Angular, Vue, Svelte (alpha) Rebuilt in 1.62 around a story gallery and a mount fixture
Reusing a login cy.session() A setup project plus storageState

Key takeaways

  • Pick Cypress when your team writes JavaScript, tests one origin in one tab, and values the interactive runner most.
  • Pick Playwright when you need Python, Java or .NET, WebKit, several tabs or origins, or free parallel runs across CI machines.
  • Don't trust anyone's speed numbers, including a vendor's. Time your own suite on your own machine. A script for that is below.

How each one runs your test

The architecture explains almost every difference in the table, so start here.

Architecture of Cypress running in the browser compared with Playwright driving the browser from a separate process

Cypress runs inside the browser. The Cypress docs say your test executes in the same run loop as your application, while a Node server process handles work the browser can't do, such as files, screenshots and network control. Being that close to the app is a real strength. Your test can reach window, stub app functions and control timers directly.

The same design sets the limits. Cypress's trade-offs page lists them plainly: one browser at a time, no native multi-tab support, and JavaScript as the only test language, permanently. A second origin needs cy.origin().

Playwright drives the browser from outside. Your test runs in Node worker processes, and each worker launches its own browser (parallelism docs). Because the test sits outside the page, a new tab is just another Page object and a second origin is just another URL. Reaching into app internals is the price: you go through page.evaluate() rather than calling app code directly.

The same test in Cypress and in Playwright

The demo shop has a login page, a product list with a cart counter, and a checkout. Placing an order takes about 600 ms, so neither tool can find the confirmation instantly. Each suite logs in once and reuses the session, then runs two tests: buy a mug, and try to order with an empty cart.

Playwright

Playwright reuses a login through a setup project. It signs in once, saves cookies and localStorage to a file, and every test starts from that file (authentication docs).

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

// Runs once before the chromium project and saves the signed-in cookies and localStorage.
setup('sign in', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('shopper@example.test');
  await page.getByLabel('Password').fill(process.env.SHOP_PASSWORD ?? 'demo-password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL('/shop');
  await page.context().storageState({ path: 'playwright/.auth/shopper.json' });
});
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';

test('a signed-in shopper can buy a mug', async ({ page }) => {
  await page.goto('/shop');
  const mug = page.getByRole('listitem').filter({ has: page.getByRole('heading', { name: 'Trail Mug' }) });
  await mug.getByRole('button', { name: 'Add to cart' }).click();
  await expect(page.getByTestId('cart-count')).toHaveText('1');

  await page.getByRole('link', { name: /Cart/ }).click();
  await expect(page.getByRole('heading', { name: 'Checkout' })).toBeVisible();
  await page.getByLabel('Full name').fill('Ada Shopper');
  await page.getByLabel('Address').fill('1 Test Street');
  await page.getByRole('button', { name: 'Place order' }).click();

  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
  await expect(page.getByTestId('order-id')).toHaveText(/^ORD-\d{4}$/);
});

test('an empty cart cannot be ordered', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByLabel('Full name').fill('Ada Shopper');
  await page.getByLabel('Address').fill('1 Test Street');
  await page.getByRole('button', { name: 'Place order' }).click();

  await expect(page.getByRole('alert')).toHaveText('Your cart is empty');
});

Add playwright/.auth to .gitignore. The Playwright docs warn that the saved state can contain cookies that let someone impersonate the test user.

Cypress

Cypress reuses a login with cy.session(). The setup function runs once per session id. Later calls restore cookies, localStorage and sessionStorage, and validate checks the restored session still works. With test isolation on (the default), the page is cleared, so each test calls cy.visit() itself.

// cypress/support/sign-in.ts
// Signs in once per spec run and restores the cookie for every later test (cy.session).
export const signIn = () => {
  cy.session(
    'shopper',
    () => {
      cy.env(['SHOP_PASSWORD']).then(({ SHOP_PASSWORD }) => {
        cy.visit('/login');
        cy.getByRole('textbox', { name: 'Email' }).type('shopper@example.test');
        // A password input has no ARIA role, so getByRole can't find it.
        cy.get('input[name="password"]').type(SHOP_PASSWORD ?? 'demo-password', { log: false });
        cy.getByRole('button', { name: 'Sign in' }).click();
        cy.location('pathname').should('eq', '/shop');
      });
    },
    {
      validate() {
        cy.getCookie('session').should('exist');
      },
    },
  );
};
// cypress/e2e/checkout.cy.ts
// cy.getByRole and cy.env need Cypress 16.2 or later.
import { signIn } from '../support/sign-in';

describe('checkout', () => {
  beforeEach(signIn);

  it('a signed-in shopper can buy a mug', () => {
    cy.visit('/shop');
    cy.contains('li', 'Trail Mug').within(() => {
      cy.getByRole('button', { name: 'Add to cart' }).click();
    });
    cy.get('[data-testid="cart-count"]').should('have.text', '1');

    cy.getByRole('link', { name: /Cart/ }).click();
    cy.getByRole('heading', { name: 'Checkout' }).should('be.visible');
    cy.getByRole('textbox', { name: 'Full name' }).type('Ada Shopper');
    cy.getByRole('textbox', { name: 'Address' }).type('1 Test Street');
    cy.getByRole('button', { name: 'Place order' }).click();

    cy.getByRole('heading', { name: 'Order confirmed' }).should('be.visible');
    cy.get('[data-testid="order-id"]').invoke('text').should('match', /^ORD-\d{4}$/);
  });

  it('an empty cart cannot be ordered', () => {
    cy.visit('/checkout');
    cy.getByRole('textbox', { name: 'Full name' }).type('Ada Shopper');
    cy.getByRole('textbox', { name: 'Address' }).type('1 Test Street');
    cy.getByRole('button', { name: 'Place order' }).click();

    cy.getByRole('alert').should('have.text', 'Your cart is empty');
  });
});

Three details here are new or easy to miss in 2026:

  • cy.getByRole() arrived in Cypress 16.2.0 (docs). Before that, role queries came from Testing Library's plugin. A string name must match the whole accessible name, case-sensitive.
  • Cypress.env() is gone in Cypress 16 (changelog). Secrets come through the async cy.env(), which also reads CYPRESS_* variables, so CYPRESS_SHOP_PASSWORD fills SHOP_PASSWORD. Non-secret values use Cypress.expose().
  • There is no getByLabel in Cypress. A password field has no ARIA role, so you fall back to a CSS selector or a label lookup.

The two configs side by side

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/shopper.json' },
      dependencies: ['setup'],
    },
  ],
  webServer: {
    command: 'node shop/server.mjs',
    url: 'http://localhost:3000/login',
    reuseExistingServer: !process.env.CI,
  },
});
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    supportFile: false,
    retries: { runMode: 2, openMode: 0 },
  },
});

Playwright's webServer starts the app for you. Cypress expects the app to be running already, so CI pipelines usually start it in an earlier step. If both tools live in one repo, give Cypress its own cypress/tsconfig.json with "types": ["cypress"]. That keeps Cypress's global cy and its global Chai expect out of your Playwright files. Cypress also refuses to bundle TypeScript specs without a tsconfig.json.

Waiting and retries

Both tools wait for you, but they wait in different places.

Cypress retries queries and assertions. Per the retry-ability guide, queries such as cy.get() re-run until the chained assertion passes or 4 seconds pass (defaultCommandTimeout). Actions such as .click() wait until the element is actionable, then run exactly once. If the app re-renders after a click, only the query chain after the click is retried.

Playwright waits inside each action and each web-first assertion. Before a click, it checks that the element is visible, stable, enabled and able to receive events (actionability). expect(locator).toHaveText() retries until it passes or times out. In the checkout test, toBeVisible() on "Order confirmed" simply waits out the 600 ms order request in both tools. Neither needs a fixed sleep.

Test retries are off by default in both. Cypress sets them per mode with retries: { runMode, openMode } (test retries). Playwright uses one retries number and reports a test that passed on retry as "flaky" (retries). Playwright also throws away the worker and its browser after a failure, so a retry starts clean. For how to treat retries without hiding real bugs, see Playwright retries and Cypress flaky tests.

Tabs, origins and iframes

This is where the architectures differ most. The shop links to a shipping policy on 127.0.0.1:3000. That's a different origin from localhost:3000, and the link opens in a new tab.

// tests/policy.spec.ts
import { test, expect } from '@playwright/test';

test('the shipping policy opens in a new tab on another origin', async ({ page }) => {
  await page.goto('/shop');
  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Shipping policy' }).click();
  const policy = await popupPromise;

  await expect(policy).toHaveURL('http://127.0.0.1:3000/policy');
  await expect(policy.getByRole('heading', { name: 'Shipping policy' })).toBeVisible();
});

Playwright starts listening for the popup before the click, then works with the new tab as an ordinary page (pages docs).

// cypress/e2e/policy.cy.ts
import { signIn } from '../support/sign-in';

describe('shipping policy', () => {
  beforeEach(signIn);

  it('opens on another origin', () => {
    cy.visit('/shop');
    // Cypress drives one tab, so drop target="_blank" and follow the link in this tab.
    cy.getByRole('link', { name: 'Shipping policy' }).invoke('removeAttr', 'target').click();
    // 127.0.0.1 is a different origin from localhost: commands there must run inside cy.origin.
    cy.origin('http://127.0.0.1:3000', () => {
      cy.getByRole('heading', { name: 'Shipping policy' }).should('be.visible');
    });
  });
});

Cypress needs two workarounds. It removes target="_blank" so the page loads in the one tab Cypress controls. Then it wraps the commands in cy.origin(). Leave out cy.origin() and the test fails after the 4-second timeout with:

CypressError: Timed out retrying after 4000ms: The command was expected to run against origin `http://localhost:3000` but the application is at origin `http://127.0.0.1:3000`.

Since Cypress 14.0, cy.origin() is needed for any second origin, even a subdomain of the same site (cross-origin guide). The callback isn't a closure. Outer variables don't exist inside it, so you pass data through args, and it can't run commands inside an iframe or another tab (cy.origin docs).

Real second tabs in Cypress need the @cypress/puppeteer plugin, which the trade-offs page names for this job. Its README calls it a public beta that works in Chromium-family browsers only.

iframes. Cypress lists iframe support as a temporary trade-off: it works, with limits, and a command to switch into an iframe is still only a proposal. In Playwright you scope locators to a frame with page.frameLocator('#payment').getByLabel('Card number') (frames). If your app embeds a payment form or a third-party widget, test that step in a prototype before you commit to either tool.

Browsers and languages

Cypress (launching browsers) supports Chrome-family browsers (Chrome, Chromium, Edge) and Firefox 140 or later. Electron is deprecated as a test browser in Cypress 16. Yet in our run, cypress run without --browser still picked Electron and printed a deprecation warning, so pass --browser chrome in CI now. WebKit is experimental. You turn on experimentalWebKitSupport and install the playwright-webkit package, which supplies the WebKit build. Under WebKit, cy.origin() isn't supported.

Playwright (browsers) downloads its own Chromium, Firefox and WebKit builds, matched to each release. It runs branded Chrome and Edge through channel, but not branded Safari. For the closest match to Safari, the docs suggest running WebKit on macOS. To add WebKit to the config above, add one more project (fragment):

// fragment: add inside projects[] in playwright.config.ts
{
  name: 'webkit',
  use: { ...devices['Desktop Safari'], storageState: 'playwright/.auth/shopper.json' },
  dependencies: ['setup'],
},

Languages. Cypress tests are JavaScript or TypeScript, and the trade-offs page says that won't change. Playwright has official bindings for TypeScript/JavaScript, Python (with the pytest plugin), Java and .NET (languages). If your QA team writes Python, that settles it. See Playwright with pytest.

Parallel runs and CI

Playwright runs test files in parallel worker processes by default. fullyParallel: true also parallelises tests within a file. Across machines you pass --shard=1/4 through --shard=4/4. Then you merge the blob reports with npx playwright merge-reports (sharding). It needs no paid service. More in Playwright sharding.

Cypress, in one cypress run, works through spec files one after another (you can watch it print "1 of 2", "2 of 2"). Its built-in parallelisation spreads spec files across machines and balances them using past run times. It requires --record, which means a Cypress Cloud project and a record key (parallelization). Without Cloud, you can still split specs yourself, giving each CI job its own --spec glob. You just lose the automatic balancing.

Debugging: the Cypress runner vs the trace viewer

npx cypress open                              # Cypress app: pick a spec, watch it run
npx playwright test --ui                      # Playwright UI mode
npx playwright show-trace test-results/<test-folder>/trace.zip   # open a recorded trace

Cypress's interactive runner is still its signature feature. The Command Log lists every command. Hover one and the app snaps back to how it looked at that moment. Click it to pin that snapshot and print details to DevTools. Because the test runs in the page, you debug with the browser's own DevTools. For failures in CI, Cypress Cloud offers Test Replay.

Playwright gives you two tools. UI mode has a timeline of DOM snapshots, watch mode and a locator picker. The trace viewer opens a trace.zip from CI with each action, before-and-after DOM snapshots, network, console and the source line. With trace: 'on-first-retry', as in the config above, a CI retry records one. The viewer at trace.playwright.dev runs in your browser and doesn't upload the file. More in Playwright trace viewer.

Component testing

Cypress mounts components in a real browser, with official support for React, Angular, Vue and Svelte (Svelte is alpha), on Vite or webpack (component testing). It's mature and well documented.

Playwright changed course in 1.62. Component tests are now ordinary tests against a "story gallery" page that your own dev server serves, driven by a built-in mount fixture (components). The old @playwright/experimental-ct-* packages are discontinued, and the docs tell anyone still on them to stay on 1.62 until they migrate. The new model is framework-agnostic but young. If component testing is central for you, Cypress has the longer track record today.

Where Cypress wins

  • The interactive runner. Time travel through the Command Log is the fastest way to see why a test did what it did while you write it.
  • Direct access to the app. Stubbing app functions, reading a store or controlling timers is straightforward when your test shares the page's run loop.
  • Component testing with broad, mature framework support.
  • Plugins and know-how. Years of plugins and recipes. Check each plugin's status, since some (like @cypress/puppeteer) are beta.
  • One language. If your whole team writes TypeScript, the JavaScript-only limit costs you nothing.

Where Playwright wins

  • Tabs, origins and iframes are ordinary API calls, not workarounds.
  • WebKit and Firefox ship with each release. Cypress's WebKit is experimental.
  • Languages. Python, Java and .NET teams can use it without learning TypeScript.
  • Free parallelism with local workers and --shard.
  • CI debugging with traces you open locally, with no service required.

How to choose for your team

Run this checklist against your real app, not a demo:

  1. List your hard flows. Do any open a new tab, cross origins (SSO, payments) or put inputs inside an iframe? Several of those point to Playwright.
  2. Check your language. A Python or Java QA team points to Playwright.
  3. Check your browsers. If Safari matters, Playwright's WebKit is supported; Cypress's is experimental.
  4. Price your CI. Decide whether you want Cypress Cloud for parallel runs and Test Replay, or prefer sharding without a service.
  5. Prototype your two hardest tests in both, then time them on the same machine:
#!/usr/bin/env bash
# time-both.sh: times both suites on the same machine, against the same running app, in the same browser family.
# Run it three or more times and ignore the first run: it pays for cold caches and bundling.
set -euo pipefail

node shop/server.mjs > /dev/null 2>&1 &
SHOP_PID=$!
trap 'kill "$SHOP_PID"' EXIT
until curl -sf http://localhost:3000/login > /dev/null; do sleep 0.2; done

echo "== Playwright (Chromium) =="
time npx playwright test --reporter=dot

echo "== Cypress (Chrome) =="
time npx cypress run --browser chrome --quiet

Compare wall-clock real time, which includes startup, since that's what CI bills you for. Then run Playwright again with --workers=1 to separate per-test speed from parallelism. On a tiny suite, startup dominates, so only your own suite's numbers mean anything. If you're already on Cypress and leaning towards a switch, read Migrate from Cypress to Playwright. If you're staying, read Cypress best practices.

One recording, either framework (Blendra)

Blendra records a flow in Chrome and exports it as a test suite in your repo. Every plan exports Playwright TypeScript. On Pro+ and Team, a project can export Cypress (Beta) or Playwright Python (Beta) instead, from the same recordings and checks, so you can try a framework on real flows without rewriting them.

Before you switch, a preview lists the steps Cypress can't run, grouped as "Leaves the site's origin" and "Checks inside an iframe". Those tests are exported as it.skip with the step named in a comment, never dropped silently. Blendra's own cloud runs keep using Playwright TypeScript. The Playwright export docs cover the default.

FAQ

Is Cypress dead?

No. Cypress 16.0.0 shipped on 1 September 2026 and 16.2.0 on 13 October 2026, adding cy.getByRole() (changelog). It's actively developed. The question is fit, not survival.

Can I run Cypress and Playwright in the same repo?

Yes. The demo repo for this post does. Keep separate folders (tests/ and cypress/e2e/), a separate tsconfig.json for Cypress, and separate CI jobs. Running both during a migration is a common way to switch one flow at a time.

How long does a migration from Cypress to Playwright take?

It depends on suite size, custom commands and how much the tests rely on Cypress-only behaviour such as cy.intercept aliases and plugins. Port your two hardest specs first. How long they take tells you more than any estimate. Migrate from Cypress to Playwright has a command map.

Which is faster, Cypress or Playwright?

For your suite, measure it with the script above. Architecture predicts some of it. Playwright parallelises across local workers by default, while one cypress run works through spec files one after another. But startup cost, your app's speed and your CI machines matter just as much. Published benchmarks rarely share their suites, so you can't check them.

ComparisonCypressPlaywright

Start with the flow you test by hand every release.

Start free →