Okay, confession: I resisted moving to Playwright for two years. Every couple of months I’d read a benchmark comparing it to Cypress, nod politely, decide our test suite worked fine, and go back to whatever I was actually shipping. Then a flaky auth test cost me a Saturday morning, and by the end of that Saturday my repo had playwright.config.ts in it.
I’m not going to tell you Playwright is objectively better than Cypress. What I’ll tell you is this: after six months of running both side by side on real projects, here are the honest tradeoffs. The parts nobody puts in the “why we switched” blog posts. The parts I only figured out by breaking things.
Short version for the impatient: Playwright won for me because of multi-context tests, real cross-browser support, and better parallelism out of the box. Cypress is still the better answer if component testing is most of what you do. If neither of those sentences describes your situation, don’t switch on my say-so.
The specific test that broke me
We had a login flow that opened a Google OAuth popup in an incognito window. Cypress has historically hated two things: iframes and multi-tab flows. We’d worked around it with a mock OAuth server for CI and a manual test in staging. That combination worked fine until a subtle session bug reproduced only when the same user was logged in on two devices at once.
I tried three Cypress workarounds. Each one added a coordination layer I’d have to maintain forever. Playwright’s browserContext handles this in about six lines:
import { test, expect } from '@playwright/test';
test('cross-tab session sync', async ({ browser }) => {
const context = await browser.newContext();
const tab1 = await context.newPage();
const tab2 = await context.newPage();
await tab1.goto('/login');
await tab1.getByLabel('Email').fill('[email protected]');
await tab1.getByLabel('Password').fill('correct-horse');
await tab1.getByRole('button', { name: 'Sign in' }).click();
await tab2.goto('/dashboard');
await expect(tab2.getByText('Welcome back')).toBeVisible();
});
Two tabs, same context, real cookies, no plugin. Cypress can approximate this with cy.session and some cy.window gymnastics, but it’s an approximation, and I could feel my future self debugging it at 2am. The Playwright browser contexts docs explain the model well if you’re new to it.
What Playwright does that Cypress can’t yet
Three things flipped my opinion after that test.
Real multi-browser support. Playwright drives Chromium, Firefox, and WebKit from the same test runner. I can catch a Safari-only bug in CI without renting a Mac runner just to do so. Cypress supports Firefox in mainline now, but WebKit is still an experimental paid feature and not shipped in the open-source runner as I write this. If your users are on iOS at all, this matters more than the benchmarks suggest.
Actual parallelism. Playwright’s test runner shards work across CPU workers by default. Our suite dropped from 11 minutes to 3 minutes on the same GitHub Actions machine, no config change beyond bumping workers: 4 in playwright.config.ts. Cypress can parallelize too, but historically it’s leaned on Cypress Cloud (paid) for the smart splitting. That’s not a criticism, just a different business model.
Auto-waiting that mostly holds up. Both tools auto-wait for elements. Playwright’s built-in retries around expect() felt less flaky in practice. I’m not going to claim I have a rigorous benchmark. I have “the number of flaky reruns per week dropped from eight to one.” Take that for what it is.
None of these are magic. Playwright has its own footguns, which I’ll get to. But if your tests span multiple browsers or contexts, this is the argument I’d actually make.
The Cypress features I still miss
Fair take: I didn’t move away because Cypress is bad. I moved because our use case outgrew it. Things I still miss on a regular basis:
Time-travel debugging. Cypress’s UI, where you scrub through every command and see the DOM at each step, is still the most discoverable e2e debugging experience I’ve used. Playwright’s trace viewer is close, and honestly technically more powerful, but Cypress felt friendlier for teammates who don’t write tests every day.
Component testing. Cypress component testing feels tighter than Playwright’s for React and Vue. If most of your suite is component tests rather than full-app e2e, staying on Cypress is a defensible call. I wouldn’t move a Vue component-heavy suite to Playwright right now.
The docs. Cypress’s tutorial-style docs are easier to onboard onto. Playwright’s docs are more reference-style, which is great when you already know what you’re looking for and less great when you don’t. Reasonable people disagree here.
I’ve written about a similar “the ecosystem moved, not that the tool got worse” pattern in my Vitest migration notes. Tools don’t decay. Your needs shift.
Migrating without a full rewrite
The mistake I made first: I tried to convert every Cypress test at once. Two weeks in I had half a working suite and half a broken one, so I threw the port away and started over with a rule.
The rule: run both frameworks in parallel for two months. New tests go in Playwright. When an old Cypress test breaks, you have to choose, fix it in Cypress or port it to Playwright. That’s the whole policy. No mass migration script, no all-at-once flag day.
The conversion itself is usually mechanical. Here’s a typical before/after from our repo:
// Cypress
describe('Signup', () => {
it('creates a new account', () => {
cy.visit('/signup');
cy.get('[data-testid="email"]').type('[email protected]');
cy.get('[data-testid="password"]').type('supersecret');
cy.get('button[type="submit"]').click();
cy.url().should('include', '/onboarding');
});
});
// Playwright
import { test, expect } from '@playwright/test';
test('signup creates a new account', async ({ page }) => {
await page.goto('/signup');
await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
await page.getByRole('textbox', { name: 'Password' }).fill('supersecret');
await page.getByRole('button', { name: /sign up|submit/i }).click();
await expect(page).toHaveURL(/\/onboarding/);
});
Two things worth noting. Playwright’s getByRole locators are more resilient to markup changes than data-testid attributes, so I ended up rewriting many selectors during migration and my markup got slightly more accessible as a side effect. And expect(page).toHaveURL(...) accepts a regex, which handled our locale-prefixed routes without an extra normalization step.
For CI, the Playwright CI setup guide is a reasonable starting point. I settled on four workers, trace viewer artifacts uploaded only on failed runs, and no Docker image caching (Playwright’s install script is fast enough that caching wasn’t worth the config). Our GitHub Actions bill actually dropped, which was not a benefit I expected.
One trap worth calling out: if you use cy.intercept extensively, mapping it to page.route is easy for GETs and gets fiddly for anything involving conditional response mocking. Budget a day for that specifically.
What to try this week
If you’re on Cypress and it’s working, don’t move. Please. I’m not writing this to convince you.
If you’re feeling the same tab, context, or browser-support pain I felt, try this:
- Install Playwright side by side:
npm init playwright@latest. Point it at the same base URL your Cypress suite uses. - Pick your single flakiest Cypress test. Port only that one to Playwright. Time both runs, honestly. Count reruns over a week.
- If the Playwright version passes reliably and finishes faster, keep going, one flaky test at a time, over a couple of months. Don’t rewrite anything that isn’t already causing you pain.
That’s the whole plan. Not a manifesto. If you want the fuller stack I use for full-stack testing across API and UI, I’ve written about some of it across my project work.
The lesson I took from this migration: don’t switch tools because a blog post told you to. Switch when the specific pain your current tool causes outweighs the switching cost. In our case, one lost Saturday was enough. In yours, it might never be. Either answer is fine.