Home / Blog / Playwright vs Cypress
Playwright vs Cypress: what the difference costs you
Playwright drives the browser from outside, over one connection. Cypress 15 runs your test code inside the browser, next to the application. Cross-origin navigation, second tabs, browser engines, language support and parallel runs all follow from that. Playwright 1.62 is the better default for broad end-to-end coverage; Cypress is still the better debugging session.
We work in Playwright and nothing else, so read this knowing which side of it we sell. What follows will not tell you that Cypress was a mistake. Comparisons age fast, so every claim below names the version it is about: Cypress 15.21.1, released on 25 August 2026.
Is Playwright better than Cypress?
For a suite that has to cover more than one browser engine, more than one origin and more machines than you own, yes: Playwright 1.62 does all three with nothing bolted on, and Cypress 15.21.1 reaches each of them through something — a callback, a flag, a recorded run. For a single-origin application on one engine, with a team that lives in the runner while it writes tests, you would be paying for a rewrite to buy capability you are not using. Better is a property of what your suite has to cover, and both readings are common.
Here is what each tool does, with nothing scored.
| What it is | Cypress 15.21.1 | Playwright 1.62 |
|---|---|---|
| Execution model | Test code is evaluated inside the browser, alongside the application under test. | Test code runs in Node and drives the browser over one connection, with a fresh browser context per test. |
| Browser engines | Chrome-family browsers including Edge and Electron, plus Firefox. WebKit runs behind the experimentalWebKitSupport flag, which picks up an installed playwright-webkit. |
Chromium, Firefox and WebKit, installed and run unflagged; branded Chrome and Edge through channel. Neither tool's WebKit is Safari on an iPhone. |
| Test languages | JavaScript, as a stated permanent decision: "The only language we'll ever support is the language of the web: JavaScript." | TypeScript and JavaScript, Python, Java and .NET. |
| Parallel runs across machines | Spec files are load-balanced across machines by Cypress Cloud. The documentation: "Running tests in parallel requires the --record flag be passed." |
--shard=1/4 on each machine, a blob reporter in the config, and merge-reports to rebuild one HTML report. |
| Cross-origin navigation | "Each test is bound to a single superdomain." A second origin is reached inside a cy.origin() callback, which has its own scoping rules. |
A browser context spans origins, so a second origin is an ordinary page.goto() in the middle of the test. |
| Multiple tabs | "Cypress does not support controlling more than 1 open browser at a time." Tabs are tested "using our @cypress/puppeteer plugin". |
Each browser context can host multiple pages, and a popup is caught by holding page.waitForEvent('popup') before the click. |
| Component testing | Mounting libraries with a published matrix: React 18–19, Vue 3, Angular 18–21, Next.js 14–16 and Svelte 5 (marked Alpha), on Vite 5–8 or Webpack 5. | Stories plus a gallery page you own, mounted through fixtures.mount(). Framework-agnostic, and stable since 1.62. |
What is the difference between Playwright and Cypress?
Where your test code is when it runs. Everything in the table above is downstream of that, and the best source for it is Cypress itself, because being told this by the incumbent's own documentation is not something anybody argues with.
The Cypress trade-offs page, stamped 24 August 2026, says that "your test code is being evaluated inside the browser" and that "Test code is not evaluated in Node, or any other server side language." It files that under permanent trade-offs, and under the same heading it says what the team gets in exchange: native access to everything in the application under test, with no serialisation and no wire protocol in between. That is why the runner is as good as it is.
Playwright puts the code on the other side. Your spec runs in Node, the browser runs as a separate process, and the runner drives it over a single connection. Each test gets its own browser context, described in the documentation as an incognito-like profile that is cheap to create. The wire itself is a subject of its own; only the location does any work here.
So the gaps people list are consequences of that placement, and not features somebody forgot to build. Code that lives in a page can see the page it lives in, and it stops there. A second origin is a different page with a different JavaScript context, so it needs a second Cypress instance and a callback shipped into it. A second tab is another browser target that nothing inside the first tab can reach, so it needs a process outside the browser. The language is the language of the browser, permanently. And the unit of work you can hand to another machine is a whole spec file, because a file is what gets loaded into a page.
Why do Cypress tests read differently from Playwright tests?
Read side by side, the two specs look like dialects of one language, and that is the most expensive illusion in this comparison. A Cypress 15.21.1 spec:
// cypress/e2e/invite.cy.ts
describe('team invites', () => {
it('adds a member', () => {
cy.visit('/settings/team');
cy.get('[data-testid=invite-email]').type('grace@example.com');
cy.get('button[type="submit"]').click();
cy.get('[data-testid=member-row]').should('have.length', 4);
});
});
And the same test in Playwright:
// tests/invite.spec.ts
import { test, expect } from '@playwright/test';
test('adds a member', async ({ page }) => {
await page.goto('/settings/team');
await page.getByTestId('invite-email').fill('grace@example.com');
await page.getByRole('button', { name: 'Send invite' }).click();
await expect(page.getByTestId('member-row')).toHaveCount(4);
});
Samples on this page: Playwright 1.62, Cypress 15.21.1, TypeScript.
Cypress's
introduction is exact about what is happening in the first file. Commands "don't do
anything at the moment they are invoked, but rather enqueue themselves to be run later",
and in case the .then() syntax suggests otherwise, "Cypress commands are not
Promises and cannot be awaited." That is not the same claim as "Cypress is
synchronous", which is the version usually repeated and is wrong: the queue is asynchronous,
and the function you wrote returns before anything in it has run.
For someone deciding, the consequence is what you can write in each. A Playwright spec is ordinary async JavaScript, so a value read off the page is a value you can branch on in the next line:
// tests/invite-seats.spec.ts
import { test, expect } from '@playwright/test';
test('tops the team up to four seats', async ({ page }) => {
await page.goto('/settings/team');
const seats = Number(await page.getByTestId('seat-count').innerText());
for (let i = seats; i < 4; i++) {
await page.getByRole('button', { name: 'Add seat' }).click();
}
await expect(page.getByTestId('seat-count')).toHaveText('4');
});
In Cypress that loop goes inside a .then() callback, because at the moment the
surrounding line runs the seat count does not exist yet. Neither shape is wrong. The Cypress
chain reads better than almost anything else in this market and it composes with the queue;
the Playwright version is longer and composes with the language. A team that writes a lot of
conditional setup feels that every week, and a team writing linear journeys through a form may
never feel it at all.
Both tools retry, which is why "Playwright waits for you" lands flat on a Cypress team. The
boundary is drawn in two different places.
Cypress's retry-ability
page has a heading reading "Only queries are retried", and under it: Cypress
"will retry any queries leading up to a command, and retry any assertions after a command,
but commands themselves only execute once." Playwright draws the line between matchers
instead.
Its assertions documentation names
the auto-retrying matchers one by one, and says of them: "The following assertions will
retry until the assertion passes, or the assertion timeout is reached. Note that retrying
assertions are async, so you must await them." The generic matchers, toBe
and toBeTruthy and the rest, run once. The instruction to await the retrying
ones is not stylistic advice, and the cost of ignoring it is the last section on this
page.
What can Playwright test that Cypress cannot?
Put that way, almost nothing. Cypress 15.21.1 has a partial answer to every item below, which is why the "Cypress can't do X" line you read somewhere is usually about a version nobody is running. Each of those answers has a price in code you write and code you keep working.
A second origin
The trade-offs page states the rule: "Each test is bound to a single superdomain.
Cross-origin navigation inside tests can be enabled by using the cy.origin
command." The history table on
cy.origin's own page
tightens it: since Cypress 14.0.0 the command is required when navigating between origins, and
not only between superdomains. An SSO login:
// cypress/e2e/sso.cy.ts
describe('sso login', () => {
it('signs in through the identity provider', () => {
const user = { email: 'grace@example.com', password: 'hunter2' };
cy.visit('/');
cy.get('[data-testid=sign-in]').click();
cy.origin('https://id.example.com', { args: user }, ({ email, password }) => {
cy.get('input[name=email]').type(email);
cy.get('input[name=password]').type(password);
cy.get('button[type=submit]').click();
});
cy.get('[data-testid=account-menu]').should('be.visible');
});
});
The callback is the price. Cypress documents it plainly: it "is not a closure and does not
retain access to the JavaScript context in which it was declared", so anything it needs
travels through args and is moved with the structured clone algorithm, which
limits what can cross. Inside it, cy.origin(), cy.intercept() and
cy.session() all throw. Your custom commands are unavailable until you pull them
in with Cypress.require(), which needs the
experimentalOriginDependencies option turned on. And the same page's limitations
list says the callback cannot run commands in a different browser window, in a different
browser tab, or inside an <iframe> element.
Playwright needs no construct for this, because one browser context covers both origins:
// tests/sso.spec.ts
import { test, expect } from '@playwright/test';
test('signs in through the identity provider', async ({ page }) => {
await page.goto('/');
await page.getByTestId('sign-in').click();
// these three lines run on id.example.com; nothing in the test has to say so
await page.getByLabel('Email').fill('grace@example.com');
await page.getByLabel('Password').fill('hunter2');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByTestId('account-menu')).toBeVisible();
});
Cross-origin works in Cypress 15.21.1, and what it costs you is a second programming model inside one callback: no closure, no interception, no session, and your own helpers only if you re-import them. That is a much smaller claim than "Cypress can't do cross-origin", and it is the one still true after the next release.
A second tab, a second browser, an iframe
Tabs have an answer and the answer is a second automation library. Cypress's trade-offs page
says "Cypress does not support controlling more than 1 open browser at a time", and
that you can test multiple tabs "using our @cypress/puppeteer plugin":
Puppeteer drives the browser from outside, bolted to a runner that cannot. In Playwright a
popup belongs to the same context as the page that opened it, and the pattern is a promise
held before the click — const popupPromise = page.waitForEvent('popup');, the
click, then const popup = await popupPromise;.
Two browsers at once is the item Cypress 15.21.1 has no workaround for, and its documentation
says so in
the same sentence about tabs. What it offers is a redesign: for a chat feature, stub the other
participant, or drive a second connection from a process outside the browser. That is
frequently the better test. Playwright's route is shorter, because its isolation page says
Playwright "can create multiple browser contexts within a single scenario" and names
multi-user functionality as the reason. Two browser.newContext() calls, two
logged-in users, one assertion that the message crossed.
Iframes are where Cypress 15 files itself under temporary: "iframe support is somewhat
limited, but does work", same-origin iframes are queryable natively, and a "switch
into an iframe" command is still an open proposal. Playwright reaches into one with
page.frameLocator('.frame-class') and locates inside it as normal. If your
checkout embeds a payment form, price this row carefully; a workaround here lives in the suite
for years.
Playwright installs Chromium, Firefox and WebKit and runs all three unflagged, and
its browser documentation says what that
WebKit is: a build derived from the main branch, where Playwright "doesn't work with the
branded version of Safari since it relies on patches." Cypress is nearer to this than it
looks, because experimentalWebKitSupport picks up an installed
playwright-webkit, the same engine behind a flag. Playwright does not give you
Safari on a real iOS device and neither does Cypress, so if you buy that coverage from a device
cloud, keep buying it. Neither tool drives a native mobile app either: cy.viewport()
and Playwright's device descriptors are browser emulation, and we do not test iOS or Android
applications.
Which is faster, and what does parallel execution cost?
We have not measured this, so there is no benchmark on this page. For a suite of any size, "faster" turns out to mean how many machines can run it and what stands between you and using them, and that part is documented rather than measured.
# Cypress 15.21.1 -- every machine runs the same command
cypress run --record --key=abc123 --parallel
# Playwright 1.62 -- each machine runs one of these
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4
# then one job, once the four blob reports are in one directory
npx playwright merge-reports --reporter html ./all-blob-reports
Cypress's
parallelisation guide is direct about the requirement: "Running tests in parallel
requires the --record flag be passed." Machines contact Cypress Cloud, which
estimates how long each spec will take and distributes spec files "one-by-one to each
available machine in a way that minimizes overall test run time", so a slow machine
simply receives fewer. The strategy is "file-based", which means a suite living in
three enormous spec files parallelises across three machines and stops there.
Playwright's split is arithmetic in your repository: each machine is told which quarter it
owns, reporter: process.env.CI ? 'blob' : 'html' goes in the config, and a final
job merges the pieces. With fullyParallel: true the shards balance test by test;
without it Playwright divides at file level, the same granularity Cypress uses. Nobody keeps
score of the run for you, which cuts both ways: Cypress's duration-weighted balancing is
smarter than dividing by four, and it needs a service to be smart in.
One thing this section is not about. Neither tool is a load tool: Cypress's trade-offs page puts performance testing among the jobs it says it is not the optimal tool for, and Playwright measures one real browser doing one thing well. It is not k6, JMeter or Gatling, and we do not sell load testing in it.
Where is Cypress still the better tool?
There are four, and the first is not a close call. Cypress 15's open mode restores the application to any moment of the test while you are writing it: "Hover over any command in the Command Log to restore the Application or Component Under Test to the state it had when that command ran. Cypress captures a snapshot for every command, which lets you time travel to previous states while you debug." Live, local, on the machine you are typing on, with DevTools open next to it. Plenty of teams chose Cypress for that and would choose it again on the same evidence.
Playwright answers with UI Mode, started by running
npx playwright test --ui, which gives a watch mode and the same hover back and
forward over each action; the claim that Playwright has no interactive session is false, and
the Inspector sits behind UI Mode for stepping. Its other answer is aimed at a failure nobody
watched:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: 1,
use: { trace: 'on-first-retry' },
});
A test that fails, retries and fails again leaves a zip.
npx playwright show-trace path/to/trace.zip opens it, or you drop the file on
trace.playwright.dev. Inside are the actions with a DOM snapshot before and after
each one, a screenshot filmstrip, the source line, the console, the network and the errors,
from a CI machine nobody logged into. Cypress owns the loop where you are writing the test;
Playwright owns the one where it broke at 3am on a machine that no longer exists. If your
team's pain is the first of those, weigh it first.
Second, component testing, where Cypress 15.21.1 is the more finished product for several stacks right now. It publishes a matrix with version numbers in it: React 18–19, Vue 3, Angular 18–21, Next.js 14–16 and Svelte 5 (marked Alpha), on Vite 5–8 or Webpack 5, with Qwik and Lit maintained by the community. The component mounts in a real browser and you debug it in open mode like anything else.
Playwright's was reworked in 1.62 and
its documentation says "It is
stable", so whatever you remember about experimental
@playwright/experimental-ct-* packages is out of date. It is a different offer
from Cypress's and worth reading as one. Framework independence is the design goal, stated as
"React, Vue, Svelte, Solid or anything else: if your dev server can render it, Playwright
can test it". It is paid for with a gallery page you own, exposing
window.mount(params) and window.unmount(), plus a named story per
scenario in place of JSX written inline in the test. The same page rules out a habit some
component suites lean on: "Accessing a component's internal methods or its instance within
test code is neither recommended nor supported." An Angular team with a working Cypress
component setup has a supported adapter today and a gallery to write tomorrow, and that goes
in the estimate.
Third, and a Playwright shop would rather not raise it: a whole bug class does not exist in
Cypress. Everything in Playwright is a promise, so a forgotten await in front of
an assertion produces a green test that asserted nothing. The command queue makes that
impossible to type. It is the most common thing that goes wrong in the first month after a
move.
Fourth, if you are a JavaScript shop and intend to stay one, Cypress's language decision is a feature and its documentation states it as one: "The only language we'll ever support is the language of the web: JavaScript." One language, one set of idioms, and no repository where the API tests ended up in Python because somebody preferred it. Playwright's Python, Java and .NET bindings are worth a great deal to somebody else's team and nothing at all to yours.
Should you switch from Cypress to Playwright?
Switch when one of these is true, and not because a table looked better:
- You need a second engine you can trust on every commit. Playwright installs Firefox and WebKit alongside Chromium and runs them with no flag and no experiment attached.
- A flow you care about is blocked. A second origin, a second tab, two users in one conversation, an iframe you cannot get into. If the workaround has been in the backlog for a year, nobody is going to build it.
- CI is the bottleneck and the way out does not fit how you run CI. The
--recordrequirement is fine if you already record, and it is a decision if you do not. - You need a language Cypress does not have. That one is permanent and stated as such, so it is a fact to plan around rather than a roadmap item to wait for.
Do not switch if none of those is true. A suite of forty specs that goes green in six minutes against Chrome, on one origin, in a repository where everyone writes JavaScript, has nothing to gain from this. The conversion costs engineer time and buys capability you are not using, and 15.21.1 landed on 25 August 2026, so nobody is being left on an abandoned tool. A runner you are not fighting is not a project.
If Cypress is not the only thing in the estate, with a Selenium suite in another repository or a Protractor job nobody has opened since Angular changed, the decision is larger than this article and gets planned as one job. We cover moving any web framework onto Playwright, and Cypress is one of the three sources we see most often.
If you have read this far and the answer is yes, the next question is not whether the idioms translate, because most of them do. It is which of them translate into nothing, and finding that out is the first phase of a Cypress to Playwright migration.
When this bites you
The suite goes green and stops testing anything. A team arrives from Cypress, where nothing is awaited because nothing can be, and writes the first of these two lines:
// tests/invite-invalid.spec.ts
import { test, expect } from '@playwright/test';
test('rejects an invalid invite address', async ({ page }) => {
await page.goto('/settings/team');
await page.getByTestId('invite-email').fill('not-an-email');
await page.getByRole('button', { name: 'Send invite' }).click();
expect(page.getByRole('alert')).toBeVisible(); // green forever
await expect(page.getByRole('alert')).toBeVisible(); // the line you meant
});
The first line builds a promise, hands it to nobody and moves on. The test function returns, the run is green, and the assertion never ran. That is worse than a flaky test, because a flaky test gets investigated and a green one does not. It is usually found by accident, months later, when somebody breaks the alert on purpose and the suite says nothing.
A linter catches it and code review does not.
@typescript-eslint/no-floating-promises
is the general form of the rule, "Require Promise-like statements to be handled
appropriately", and it needs type information to run.
eslint-plugin-playwright
carries one aimed at exactly this case, missing-playwright-await, described as
"Enforce Playwright APIs to be awaited"; it is in the plugin's recommended set and it
is auto-fixable. Turn both on in the same commit as the first converted spec, before a batch
has been reviewed and merged with the habit already in it.
The second bite is slower and costs more: the half-finished move. Two runners in one repository, the new suite covering the specs that were easy to convert, and a team that quietly keeps running the old one because that is the job covering checkout. Both pipelines stay, both need maintaining, and the Cypress suite becomes the one nobody wants to touch and nobody can delete.
Questions
Should I migrate from Cypress to Playwright?
Migrate if you need a second browser engine running unflagged on every commit, if a flow you cannot test leaves the origin or opens a tab, or if adding machines to the CI run has become the bottleneck. Stay if the suite is green, fast enough that people still wait for it, and covers one browser on one origin. Cypress 15.21.1 is a good tool, and a suite that is not costing you anything is not a project.
Can Playwright replace Cypress?
For end-to-end testing, yes. Playwright 1.62 covers what a Cypress end-to-end suite covers, and it reaches second origins, second tabs and a third engine without a plugin. Two things do not arrive as an upgrade. Cypress open mode restores the application to the state it was in at any command you hover over, and no post-mortem trace is the same thing. And Cypress ships named mounting libraries for React, Vue, Angular and Svelte, while Playwright component testing asks you to own a gallery page that renders your stories.
Is Cypress easier than Playwright?
To start, yes. Cypress commands enqueue themselves instead of resolving, so there are no promises to reason about and a forgotten await is not a mistake available to you. There is one language. And the runner puts the application next to the command log from the first test you write. Playwright asks you to understand async JavaScript on day one, and pays that back the first time a test needs ordinary control flow.
Which is better for CI/CD: Playwright or Cypress?
The difference is what stands between you and more machines. Cypress documentation says running tests in parallel requires the --record flag be passed, and Cypress Cloud then load-balances spec files across the available machines one by one. Playwright splits the run with a --shard=1/4 flag on each machine, a blob reporter in the config, and npx playwright merge-reports to put one HTML report back together afterwards.
Does Playwright test Safari on an iPhone?
No. Playwright installs a WebKit build and runs it unflagged in CI next to Chromium and Firefox, which is a second real engine and worth having. It is not Safari on a real device, and Playwright's browser documentation says it does not work with the branded version of Safari because it relies on patches. If you buy iOS device coverage from a device cloud today, keep it.
Before you commit a quarter to this, send us two things
How many spec files are in the suite, and which of the four conditions above is the one biting you: a second engine, a blocked flow, a CI job that will not divide, or a language. With those in front of us the first call is about your repository instead of about two frameworks.