Cypress dominated end-to-end testing for years. Playwright, released by Microsoft in 2020, caught up fast — and in many benchmarks now surpasses it. The key difference: Cypress runs inside the browser; Playwright controls browsers from outside via the Chrome DevTools Protocol and WebDriver BiDi. This guide tells you exactly what that means in practice and which to pick.
At a glance
| Playwright | Cypress | |
|---|---|---|
| Vendor | Microsoft | Cypress.io |
| Released | 2020 | 2014 |
| Language support | JS/TS, Python, Java, C# | JS/TS only |
| Browser support | Chromium, Firefox, WebKit (Safari) | Chrome, Edge, Firefox (no WebKit) |
| Architecture | Out-of-process (CDP + WebDriver BiDi) | In-browser (iframe + proxy) |
| Parallelisation | Built-in (free) | Requires Cypress Cloud (paid) |
| GitHub stars (2025) | ~65k | ~47k |
| npm weekly downloads | ~10M | ~6M |
| Auto-wait | Yes | Yes |
| Test isolation | Per-test browser context | Per-test (shared origin cookies by default) |
How Playwright works
Playwright runs as a Node.js process outside the browser. It communicates with Chromium, Firefox, and WebKit via the Chrome DevTools Protocol (CDP) and the newer WebDriver BiDi standard. Each test gets its own BrowserContext — an isolated browser session with separate cookies, localStorage, and cache — without the cost of launching a full browser.
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
});
How Cypress works
Cypress runs inside the browser in an iframe alongside your application. This gives it direct DOM access and synchronous JavaScript execution, but limits it to one browser tab, one origin (by default), and makes network stubbing work via a Node.js proxy rather than native browser interception.
// cypress.config.js
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
setupNodeEvents(on, config) {},
specPattern: "cypress/e2e/**/*.cy.{js,ts}",
video: true,
screenshotOnRunFailure: true,
},
});
Writing the same test: side by side
Scenario: Log in, navigate to dashboard, assert a welcome message.
Playwright
// tests/login.spec.ts
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/login");
});
test("user can log in and sees dashboard", async ({ page }) => {
await page.getByLabel("Email").fill("user@example.com");
await page.getByLabel("Password").fill("secret123");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible();
});
test("shows error for wrong password", async ({ page }) => {
await page.getByLabel("Email").fill("user@example.com");
await page.getByLabel("Password").fill("wrong");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByText("Invalid credentials")).toBeVisible();
});
Cypress
// cypress/e2e/login.cy.ts
describe("Login", () => {
beforeEach(() => {
cy.visit("/login");
});
it("user can log in and sees dashboard", () => {
cy.get('input[name="email"]').type("user@example.com");
cy.get('input[name="password"]').type("secret123");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/dashboard");
cy.get("h1").should("contain.text", "Welcome");
});
it("shows error for wrong password", () => {
cy.get('input[name="email"]').type("user@example.com");
cy.get('input[name="password"]').type("wrong");
cy.get('button[type="submit"]').click();
cy.contains("Invalid credentials").should("be.visible");
});
});
Key syntax differences:
- Playwright uses
await(real async); Cypress uses a custom command queue - Playwright prefers semantic locators (
getByLabel,getByRole); Cypress uses CSS selectors by convention - Playwright assertions use
expect(locator).toBeVisible(); Cypress chains.should()
Performance comparison
| Scenario | Playwright | Cypress |
|---|---|---|
| Cold start | ~2s | ~4s |
| Per-test browser context | ~50ms | ~200ms (page reload) |
| Parallel execution | Free, multi-worker | Paid (Cypress Cloud) or manual sharding |
| 100-test suite (serial) | ~3 min | ~5 min |
| 100-test suite (parallel, 4 workers) | ~55s | ~55s (Cloud) / ~5 min (free) |
| Memory per worker | ~80 MB | ~200 MB |
| CI cold start overhead | Low (headless default) | Medium (Electron default) |
API comparison
| Feature | Playwright | Cypress |
|---|---|---|
| Locator strategy | getByRole, getByLabel, getByText, locator() |
cy.get() CSS/XPath, cy.contains(), Testing Library plugin |
| Auto-wait timeout | Configurable (default 30s) | Configurable (default 4s per assertion) |
| Network interception | page.route() — intercepts at browser level |
cy.intercept() — via Node proxy |
| Multi-tab / popup | Native (context.newPage(), page.waitForPopup()) |
Limited (single tab per test, experimental multi-origin) |
| iframes | frame() / frameLocator() |
cy.iframe() (plugin) or cy.get('iframe').its('0.contentDocument') |
| File download | page.waitForEvent('download') |
cy.readFile() after configuring download path |
| File upload | locator.setInputFiles() |
cy.get('input').selectFile() |
| Visual comparison | expect(page).toHaveScreenshot() (built-in) |
cy.matchImageSnapshot() (plugin) |
| Accessibility testing | @axe-core/playwright |
cypress-axe plugin |
| API testing | request.get/post/put/delete (built-in) |
cy.request() (built-in) |
| Component testing | @playwright/experimental-ct-* |
First-class (React, Vue, Angular, Svelte) |
| Code generation | npx playwright codegen |
Cypress Studio (experimental) |
| Test reporter | HTML, JSON, JUnit, Allure | Mochawesome, JUnit, Cypress Dashboard |
Browser support
| Browser | Playwright | Cypress |
|---|---|---|
| Chrome / Chromium | ✅ | ✅ |
| Edge (Chromium) | ✅ | ✅ |
| Firefox | ✅ | ✅ (limited devtools) |
| WebKit (Safari engine) | ✅ | ❌ |
| Safari (real) | macOS only | ❌ |
| Mobile emulation | ✅ (Chromium + WebKit) | ✅ (viewport only, no real mobile) |
| Real device testing | Via cloud providers | Via cloud providers |
WebKit support is the clearest Playwright advantage — you can catch Safari-specific bugs without owning a Mac.
Where Playwright wins
| Scenario | Why Playwright wins |
|---|---|
| Cross-browser testing | WebKit support catches Safari bugs in CI |
| Large test suites | Free parallelisation across workers |
| Multi-language teams | Python, Java, C# SDKs available |
| Multi-tab / multi-origin | Native support with no hacks |
| Performance-critical CI | Faster per-test isolation via BrowserContext |
| Tracing & debugging | Built-in Trace Viewer with timeline, network, snapshots |
| API + UI testing | APIRequestContext built in, no extra setup |
| Open source cost | Fully free parallel testing |
| Mobile web | Real WebKit emulation |
| Newer architecture | WebDriver BiDi future-proofs the tool |
Where Cypress wins
| Scenario | Why Cypress wins |
|---|---|
| Component testing | Mature, first-class support for React/Vue/Angular/Svelte |
| Developer UX | Time-travel debugger, visual test runner in browser |
| Learning curve | Simpler mental model for small teams, no async awareness needed |
| Error messages | Excellent human-readable failure messages |
| Community plugins | Larger plugin ecosystem (cypress-axe, cypress-testing-library, etc.) |
| JavaScript-only teams | Single language, no context switching |
| Stubbing/mocking | Simpler cy.intercept() API for common patterns |
| Mature docs | Deeper guides, recipes, and examples |
| Real-time reloads | Watch mode rebuilds instantly in the interactive runner |
| Migration from older tools | Easiest Selenium migration path for JS teams |
Project setup comparison
Playwright setup
npm init playwright@latest
# Installs browsers, creates playwright.config.ts, example tests
npx playwright test # run all tests
npx playwright test --ui # open interactive UI mode
npx playwright codegen # record a test
npx playwright show-report # open HTML report
Cypress setup
npm install cypress --save-dev
npx cypress open # interactive test runner (browser)
npx cypress run # headless run
npx cypress run --spec "cypress/e2e/login.cy.ts"
CI/CD integration
Playwright (GitHub Actions)
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
Cypress (GitHub Actions)
# .github/workflows/cypress.yml
name: Cypress Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm start
wait-on: "http://localhost:3000"
- uses: actions/upload-artifact@v4
if: always()
with:
name: cypress-screenshots
path: cypress/screenshots
Network mocking comparison
Both tools let you intercept HTTP requests, but the implementation differs.
Playwright
test("shows cached products", async ({ page }) => {
// Intercept at browser level — no proxy needed
await page.route("**/api/products", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Laptop", price: 999 }]),
});
});
await page.goto("/products");
await expect(page.getByText("Laptop")).toBeVisible();
});
Cypress
it("shows cached products", () => {
cy.intercept("GET", "/api/products", {
statusCode: 200,
body: [{ id: 1, name: "Laptop", price: 999 }],
}).as("getProducts");
cy.visit("/products");
cy.wait("@getProducts");
cy.contains("Laptop").should("be.visible");
});
Debugging experience
| Feature | Playwright | Cypress |
|---|---|---|
| Trace Viewer | ✅ Timeline, DOM snapshots, network waterfall | ❌ |
| Time-travel debugging | Via Trace Viewer (post-run) | ✅ Live in interactive runner |
| Screenshot on failure | ✅ Auto | ✅ Auto |
| Video on failure | ✅ | ✅ |
--debug mode |
Inspector pauses test for step-through | cy.debug() / cy.pause() |
| VS Code extension | ✅ (run/debug tests in editor) | ✅ (Cypress extension) |
| Network panel | In Trace Viewer | In DevTools (interactive runner) |
Full comparison
| Feature | Playwright | Cypress |
|---|---|---|
| Architecture | Out-of-process | In-browser |
| Languages | JS/TS, Python, Java, C# | JS/TS only |
| Browser support | Chromium, Firefox, WebKit | Chrome, Edge, Firefox |
| Parallelisation | Free built-in | Paid (Cypress Cloud) |
| Component testing | Experimental | First-class |
| Auto-wait | Yes | Yes |
| Network intercept | Browser-level (CDP) | Proxy-level |
| Multi-tab | Yes | No (workaround needed) |
| Multi-origin | Yes | Limited (cy.origin) |
| iframes | frameLocator() | Plugin or workaround |
| Visual testing | Built-in screenshots | Plugin |
| API testing | Built-in APIRequestContext | cy.request() |
| Trace / replay | Full trace viewer | Time-travel in runner |
| Mobile emulation | Real WebKit engine | Viewport resize only |
| Code generation | playwright codegen |
Cypress Studio |
| Price for parallelism | Free | Cypress Cloud subscription |
| Learning curve | Moderate (async/await) | Gentle (queued commands) |
| GitHub stars (2025) | ~65k | ~47k |
| Community / plugins | Growing | Mature |
| Corporate backing | Microsoft | Cypress.io (VC-backed) |
Migration: Cypress → Playwright
| Cypress | Playwright equivalent |
|---|---|
cy.visit(url) |
await page.goto(url) |
cy.get('button') |
page.locator('button') |
cy.contains('text') |
page.getByText('text') |
cy.get('input[name="email"]') |
page.getByLabel('Email') or page.locator('...') |
cy.click() |
await locator.click() |
cy.type('hello') |
await locator.fill('hello') |
cy.should('be.visible') |
await expect(locator).toBeVisible() |
cy.url().should('include', '/path') |
await expect(page).toHaveURL(/path/) |
cy.intercept(method, url, body) |
await page.route(url, route => route.fulfill(...)) |
cy.wait('@alias') |
await page.waitForResponse(url) |
cy.request(url) |
await request.get(url) |
beforeEach(() => cy.visit()) |
test.beforeEach(async ({ page }) => await page.goto()) |
Decision guide
Do you need Safari/WebKit testing?
├── Yes → Playwright
Do you need multi-tab or multi-origin tests?
├── Yes → Playwright
Is your team using Python, Java, or C#?
├── Yes → Playwright
Do you want free parallelisation?
├── Yes → Playwright
Is component testing a priority?
├── Yes → Cypress (more mature)
Does your team prefer a visual, in-browser runner for daily development?
├── Yes → Cypress
Are you migrating from Selenium with a JS/TS team?
├── Yes → Cypress (easier conceptual jump)
Are you starting fresh with a modern JS/TS project?
├── → Playwright (default recommendation 2025)
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
Not using getByRole/getByLabel in Playwright |
Brittle CSS selectors break on UI refactors | Use semantic locators; add data-testid sparingly |
Forgetting await in Playwright |
Silent failures — test passes without actually waiting | Use TypeScript to catch missing await at compile time |
| Mixing Cypress queue and async/await | Cypress commands aren't real promises; using async/await incorrectly breaks the queue |
Stick to Cypress chaining OR use cy.then() for async work |
Not setting baseURL |
Hardcoded URLs make tests fail in different environments | Configure baseURL in playwright.config.ts / cypress.config.js |
| Shared state between Cypress tests | Default cy.session() or persistent cookies cause test order dependency |
Use cy.clearCookies() / cy.clearLocalStorage() in beforeEach |
| Running Playwright in serial on CI | Wasted parallelisation opportunity | Set fullyParallel: true and use multiple workers |
| Ignoring Playwright Trace Viewer | Debugging CI failures blind | Always enable trace: 'on-first-retry' in CI config |
| Skipping flaky test analysis | Flaky tests erode trust in the whole suite | Use Playwright's --retries + --reporter=list to identify and quarantine flaky tests |
Cypress vs Playwright vs Selenium vs WebdriverIO
| Cypress | Playwright | Selenium | WebdriverIO | |
|---|---|---|---|---|
| Architecture | In-browser | Out-of-process CDP | WebDriver protocol | WebDriver + DevTools |
| Languages | JS/TS | JS/TS, Python, Java, C# | All major | JS/TS |
| Safari/WebKit | ❌ | ✅ | ✅ (via SafariDriver) | ✅ |
| Speed | Fast | Fastest | Slowest | Fast |
| Parallelism | Paid | Free built-in | Manual | Built-in |
| Component tests | ✅ First-class | Experimental | ❌ | ✅ |
| Maturity | 2014 (Mature) | 2020 (Fast-growing) | 2004 (Legacy) | 2015 (Mature) |
| Learning curve | Easy | Moderate | Steep | Moderate |
| Best for | JS teams, component + E2E | Cross-browser, CI/CD, multi-lang | Legacy projects | WebDriver + DevTools hybrid |
FAQ
Is Playwright replacing Cypress? Playwright has overtaken Cypress in npm downloads and GitHub stars in 2025. For new projects — especially cross-browser or multilingual teams — Playwright is now the default recommendation. Cypress remains the better choice for component testing and teams that value its interactive runner.
Can I use both Playwright and Cypress in the same project? Yes, though it's unusual. Some teams run Playwright for E2E cross-browser tests and Cypress for component tests (React/Vue). The maintenance overhead doubles, so only do this if the tradeoff is worth it.
Which is faster — Cypress or Playwright? Playwright is faster in CI, mainly because: (1) free parallelisation across workers, (2) lighter per-test BrowserContext vs full page reload, (3) headless Chromium default. For a 100-test suite, Playwright typically runs 30–50% faster with 4 workers vs free-tier Cypress.
Does Playwright work with React Testing Library?
Not directly — RTL is a unit/component testing tool. Playwright has @playwright/experimental-ct-react for component tests, but for E2E, you write tests against the full running app. Use semantic locators (getByRole, getByLabel) which share the same accessibility-first philosophy as RTL.
Which should I learn first? If you're learning E2E testing for the first time: Playwright. Its modern async/await API matches how JavaScript works, the docs are excellent, and it's where the industry is heading. Cypress's queued command model is a non-standard mental model you'll have to unlearn later.
Does Playwright support mobile testing? Playwright supports mobile emulation (WebKit + Chromium with device profiles) for web tests. It does not test native iOS/Android apps. For real device mobile web testing, use cloud providers like BrowserStack or Sauce Labs with Playwright's remote connection support.