50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
best-practicesMay 18, 2026·8 min read

Playwright vs Cypress in 2025: Which One Should You Actually Use?

We've shipped both in production. Here's the honest comparison — performance, DX, flakiness, and when each one actually makes sense.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Playwright vs Cypress in 2025: Which One Should You Actually Use?

We've had this argument twice. Once over coffee, once at 11pm after a flaky test blocked a deploy for the third time that week. The short version: both Playwright and Cypress are good. The longer version is that they're good at different things, and picking the wrong one for your project is the kind of decision that haunts you six months later when your CI pipeline is a disaster.

So here's what we actually think in 2025, after using both in real projects — not toy apps, not tutorials, actual SaaS products with auth flows, payments, and the kind of UI that breaks in ways you'd never predict.

The Quick Lay of the Land

Cypress launched in 2017 and basically invented the modern E2E testing experience. Before Cypress, writing browser tests felt like self-harm. It brought a great visual runner, time-travel debugging, and a developer experience that made you actually want to write tests. Playwright came from Microsoft in 2020, built by the same team that made Puppeteer, and came out swinging with cross-browser support, parallelism, and an architecture that doesn't have Cypress's fundamental limitations around iframes and multiple tabs.

In 2025, Playwright has largely won the mindshare battle among teams that care about performance and reliability. Cypress is still extremely popular — especially with teams that value its DX and ecosystem maturity. Neither is dead. Both are actively maintained. The question is which fits your situation.

Architecture: Why It Matters More Than You Think

Cypress runs inside the browser. Your test code executes in the same JavaScript runtime as your app. This is why it has such great introspection — it can see everything, intercept network requests natively, access the DOM immediately. But it's also why it can't do certain things: you can't test multiple tabs in the same test, iframes from other origins are painful, and OAuth flows that open popup windows require workarounds.

Playwright runs outside the browser and controls it through the Chrome DevTools Protocol (and equivalent for Firefox/WebKit). It's more like a puppeteer — it's driving the browser, not living inside it. This means multi-tab flows, cross-origin iframes, file downloads, and complex OAuth redirects all just work. The trade-off is that debugging feels slightly more removed.

If your app has any OAuth flow that opens a popup, or if you need to test anything across multiple browser tabs — just use Playwright. Cypress will make you cry.

Writing Tests: What the Code Actually Looks Like

Let's be concrete. Here's a basic login test in both. This is what you'll be writing every day, so syntax matters.

// Cypress — cypress/e2e/login.cy.ts
describe('Login flow', () => {
  it('logs in with valid credentials', () => {
    cy.visit('/login')
    cy.get('[data-testid="email-input"]').type('user@example.com')
    cy.get('[data-testid="password-input"]').type('supersecret')
    cy.get('[data-testid="submit-btn"]').click()
    cy.url().should('include', '/dashboard')
    cy.contains('Welcome back').should('be.visible')
  })
})
// Playwright — tests/login.spec.ts
import { test, expect } from '@playwright/test'

test('logs in with valid credentials', async ({ page }) => {
  await page.goto('/login')
  await page.getByTestId('email-input').fill('user@example.com')
  await page.getByTestId('password-input').fill('supersecret')
  await page.getByTestId('submit-btn').click()
  await expect(page).toHaveURL(/dashboard/)
  await expect(page.getByText('Welcome back')).toBeVisible()
})

Cypress reads like jQuery mixed with Mocha. If you've worked with that stack, it feels familiar and comfortable. Playwright is async/await throughout, which means it plays nicely with modern TypeScript tooling, IDE autocomplete, and your existing mental model of async JavaScript. No more mysterious chain execution order confusion.

One thing Cypress has going for it: the automatic retry logic is baked into every command. You don't think about it. Playwright's auto-waiting is also excellent, but you have to be slightly more intentional with `expect()` assertions vs just checking synchronous values.

Performance and Flakiness: The Real Pain Points

Flaky tests are the enemy of CI trust. Once your team starts ignoring red builds because "it's probably just the tests," you've lost. Both tools have gotten much better here, but they fail differently.

Cypress flakiness usually comes from timing issues with its command chain, or from tests that share state through `cy.session()` not quite working the way you expected. The visual test runner is excellent for debugging these — you can literally click on any step and see what the DOM looked like at that moment. That's still a killer feature.

Playwright flakiness is rarer in our experience, partly because the architecture handles async better and partly because it ships with a retry-on-failure option and a proper test reporter. When things do go wrong, the trace viewer is exceptional — you get a full timeline of network requests, DOM snapshots, console logs, and screenshots. It's like a flight data recorder for your test.

  • Playwright parallel execution is faster by default — tests run in parallel across workers without extra config
  • Cypress parallelism requires Cypress Cloud (paid) or careful manual setup
  • Playwright's trace viewer beats Cypress's time-travel for complex debugging scenarios
  • Cypress's visual runner beats Playwright's UI mode for quick interactive debugging on a local machine
  • Playwright handles network mocking with `page.route()` which is clean and powerful
  • Cypress's `cy.intercept()` is more ergonomic for simple cases

The Authentication Problem (We've Been There)

Every SaaS app has the same problem: you need to be authenticated for 90% of your tests, and if every test goes through the login UI, your suite is slow and brittle. Both tools have solutions. Playwright's is better.

// Playwright — reuse auth state across tests
// auth.setup.ts — runs once before the test suite
import { test as setup, expect } from '@playwright/test'
import path from 'path'

const authFile = path.join(__dirname, '../.auth/user.json')

setup('authenticate', async ({ page }) => {
  await page.goto('/login')
  await page.getByTestId('email-input').fill(process.env.TEST_EMAIL!)
  await page.getByTestId('password-input').fill(process.env.TEST_PASSWORD!)
  await page.getByTestId('submit-btn').click()
  await expect(page).toHaveURL(/dashboard/)

  // Save the auth state (cookies + localStorage) to disk
  await page.context().storageState({ path: authFile })
})

// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /auth.setup.ts/ },
    {
      name: 'authenticated tests',
      use: { storageState: '.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
})

That pattern — run login once, save the cookies and localStorage to a file, reuse it for every test — is clean and fast. With Cypress, you'd typically stub the auth entirely (set the JWT directly) or use `cy.session()`. Both work, but Playwright's approach feels closer to what a real user experiences and handles more edge cases around session expiry.

When Cypress Is Still the Right Call

We don't want to make this sound like Playwright wins unconditionally, because that's not true. There are real reasons to choose Cypress.

  • Your team is already deep in the Cypress ecosystem and the tests are passing — don't migrate for the sake of it
  • You want component testing alongside E2E in a single tool (Cypress's component testing story is mature and good)
  • You're in a React shop and the team is more comfortable with Cypress's jQuery-style API
  • You need the visual interactive runner for demos, pair programming, or onboarding new devs
  • Your app is Chrome-only and you don't care about Safari or Firefox testing

The component testing angle is real. Cypress Component Testing lets you mount a React or Next.js component in isolation and interact with it in a real browser, not jsdom. Playwright also has experimental component testing, but it's not as polished. If you're doing a lot of component-level browser testing, Cypress might still be the better tool.

Our Actual Recommendation for a New Next.js Project in 2025

Start with Playwright. Here's why: Next.js projects almost always end up with OAuth (Google, GitHub, magic links), and that means redirect chains, cross-origin requests, and sometimes popup windows. Playwright handles all of this without hacks. The async/await API fits perfectly in a TypeScript Next.js project. Parallelism is free. The trace viewer has saved us hours of debugging that would've been much worse with any other tool.

The one thing you need to set up properly is a test environment that doesn't hit your production database. Use a separate `.env.test` with a test database, seed it before your suite runs, and clean up after. This is boring infrastructure work but it's the difference between a test suite you trust and one that's a liability.

// playwright.config.ts — sensible defaults for a Next.js project
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0, // retry on CI only
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI ? 'github' : 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry', // save trace on failure
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'mobile',
      use: { ...devices['iPhone 14'] },
      dependencies: ['setup'],
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

That config runs your dev server automatically, retries failed tests on CI, saves traces when things break, and tests both desktop and mobile. Copy-paste that, adjust the paths, and you're 80% of the way to a good setup.

One more thing: if you're starting from a template, check whether it already has a testing setup. Our templates at peal.dev ship with Playwright configured out of the box — auth tests, basic smoke tests, and the CI GitHub Actions workflow included. Saves you the afternoon of fighting with config that we already fought through.

The best testing setup is the one you actually run. Don't overthink the tool choice — get something working, make it fast, and keep it green.

If you're migrating from Cypress to Playwright: it's a weekend of work for a medium-sized suite, not a month-long project. The APIs are similar enough that translation is mostly mechanical. We did it on one project and the CI time dropped from 18 minutes to 6. That alone was worth it.

Pick Playwright for new projects. Keep Cypress if it's working. Write the tests either way — because discovering your auth is broken at 2am via a user email is considerably worse than a failing test in CI.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders