Playwright vs. Selenium vs. Cypress: A Practitioner's Guide (with Migration Code)

Most Playwright-versus-Selenium-versus-Cypress comparisons are one of two things: adoption charts lifted from a vendor's marketing deck, or migration guides that only ever cover Selenium and quietly ignore Cypress. This piece is neither. It's a decision framework plus real migration code — Selenium (C#) to Playwright (TypeScript) and Cypress to Playwright — written from actually doing those migrations on client projects, with the parts that hurt called out honestly.

The 2026 landscape

Playwright has pulled decisively ahead. Recent developer-adoption surveys put Playwright's usage at roughly 45%, ahead of Selenium's ~22% and Cypress's ~14% — directionally accurate even if the exact numbers bounce around between survey vendors. The interesting detail is in the shape of those numbers. Selenium still owns a large share of its footprint in big polyglot enterprises and long-running legacy suites that aren't going anywhere soon, while Cypress holds a smaller but genuinely loyal niche among JS/TS-only product teams who value its developer experience. What follows is written from doing these migrations hands-on — not from a tool vendor's benchmark deck — so where a tool is awkward, I say so.

Architecture: why the speed and flakiness gap is real

To understand why the speed and flakiness gap is real, you have to look at how each tool talks to the browser.

Selenium drives the browser through the W3C WebDriver protocol, routed through a separate driver executable — chromedriver, geckodriver, and so on — one per browser, each with its own version to keep matched against the browser it controls. Every command crosses that extra hop, and version drift between browser, driver, and bindings is a classic source of "works on my machine" failures.

Cypress runs inside the browser, in the same JavaScript run-loop as the application under test. That's a genuine architectural advantage for network interception and time-travel debugging, and it's also the source of Cypress's hard limits: no true multi-tab support, no true multi-origin navigation, and JavaScript/TypeScript only.

Playwright talks to the browser directly over the Chrome DevTools Protocol (and its Firefox and WebKit equivalents) from outside the page context, and builds auto-waiting into every action and assertion. That architecture — not marketing — is why Playwright suites are typically faster and less flaky than Selenium's, and why Playwright can do things Cypress structurally cannot: multiple tabs, multiple origins, and multiple browser contexts within a single test.

Head-to-head

Playwright Selenium Cypress
Primary languages JS/TS, Python, .NET, Java Java, Python, C#, Ruby, JS JS/TS only
Browser communication Chrome DevTools Protocol (and Firefox/WebKit equivalents) W3C WebDriver protocol via per-browser driver executables Runs inside the browser, same run-loop as the app
Auto-waiting Built-in on actions and assertions None — explicit waits required Built-in auto-wait/retry
Native parallelization Built-in parallel workers Needs Selenium Grid Needs Cypress Cloud or manual sharding
Multi-tab / multi-origin Yes, first-class Limited / awkward No true support by design
Network interception Built-in route interception None built-in — needs BrowserMob/proxy tools Excellent built-in stubbing
Mobile / native app testing No native mobile; Android + Electron support Appium for real mobile No native mobile
Visual / trace debugging tooling Built-in trace viewer + codegen No built-in trace tooling Excellent time-travel debugger
CI story First-class CI Mature but heavier CI setup Good CI story for JS-only stacks
Best-fit team Greenfield and migrating teams; multi-language needs Large polyglot enterprises; legacy suites; Appium needs JS/TS-only teams that never need multi-tab or cross-origin

When each one is still the right call

Playwright

Pick Playwright when you're starting a new suite, when you need multi-browser (Chromium, Firefox, WebKit) or multi-language coverage, or when you're actively migrating off another tool. It's the strongest default in 2026 for greenfield work and for teams that value the built-in trace viewer and codegen.

Selenium

Stay on Selenium when you have a large, working, polyglot suite across a big enterprise team, or when your test strategy already leans on the broader Selenium ecosystem — including Appium for real mobile testing, which Playwright doesn't natively do. Ripping out a stable Selenium estate simply because it's "old" is rarely a good trade on its own.

Cypress

Keep Cypress when you're a JS/TS-only team that never needs multi-tab or multi-origin flows and you're happy with its excellent time-travel debugging and network stubbing. If Cypress's structural limits aren't hurting you, the migration cost isn't worth paying.

Code: Selenium (C#) → Playwright (TypeScript)

The most common migration we run is Selenium (C#) to Playwright (TypeScript). Here's a login test written in both, side by side.

Selenium (C#)
// Selenium (C#) — login test
[Test]
public void LoginWithValidCredentials()
{
    driver.Navigate().GoToUrl("https://app.example.com/login");

    var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
    wait.Until(ExpectedConditions.ElementIsVisible(By.Id("email")));

    driver.FindElement(By.Id("email")).SendKeys("user@example.com");
    driver.FindElement(By.Id("password")).SendKeys("Sup3rSecret!");
    driver.FindElement(By.CssSelector("button[type='submit']")).Click();

    wait.Until(ExpectedConditions.ElementIsVisible(By.ClassName("dashboard-header")));
    var header = driver.FindElement(By.ClassName("dashboard-header"));
    Assert.That(header.Text, Does.Contain("Welcome back"));
}
Playwright (TypeScript)
// Playwright (TypeScript) — same test
test('login with valid credentials', async ({ page }) => {
  await page.goto('https://app.example.com/login');

  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('Sup3rSecret!');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible();
});

What changed? The entire WebDriverWait / ExpectedConditions ceremony disappears. Playwright's locators and expect() auto-wait and auto-retry on their own, so the explicit ten-second visibility waits collapse into the actions themselves. Locators also move from raw By.Id(...) and CSS selectors to user-facing role and label queries — which is why the Email / Password labels and the accessible button name survive UI churn far better than IDs do.

Code: Cypress → Playwright

The Cypress-to-Playwright move is the closer comparison, because both tools have strong built-in network interception.

Cypress
// Cypress — add-to-cart flow
describe('cart', () => {
  it('adds a product and updates the badge', () => {
    cy.visit('/products/wireless-mouse');
    cy.intercept('POST', '/api/cart/add').as('addToCart');

    cy.get('[data-cy=add-to-cart]').click();
    cy.wait('@addToCart');

    cy.get('[data-cy=cart-badge]').should('have.text', '1');
  });
});
Playwright (TypeScript)
// Playwright (TypeScript) — same flow
test('adds a product and updates the badge', async ({ page }) => {
  await page.goto('/products/wireless-mouse');

  const addToCart = page.waitForResponse('**/api/cart/add');
  await page.getByTestId('add-to-cart').click();
  await addToCart;

  await expect(page.getByTestId('cart-badge')).toHaveText('1');
});

cy.intercept(...).as('addToCart') plus cy.wait('@addToCart') maps almost 1:1 to page.waitForResponse(). The real reason teams still move is structural: Playwright's true multi-tab, multi-origin, and multi-browser (Firefox and WebKit) support, which Cypress cannot do by design. If those limits aren't biting you, Cypress remains a fine tool.

Migration mechanics that matter

Reusable login state instead of logging in every test

The single biggest speed win is stopping logging in on every test. Playwright's storageState captures cookies and localStorage once in a global setup, then every test boots straight into an authenticated session. Here's the whole thing — a global setup that logs in once, and a config that injects the saved state into every test.

global-setup.ts
// global-setup.ts
import { chromium, type FullConfig } from '@playwright/test';

export default async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto('https://app.example.com/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('**/dashboard');

  await page.context().storageState({ path: 'storageState.json' });
  await browser.close();
}
playwright.config.ts
// playwright.config.ts
export default defineConfig({
  globalSetup: require.resolve('./global-setup'),
  use: {
    storageState: 'storageState.json',
  },
});

Replacing Selenium Grid

Most of what teams stood up Selenium Grid for — spreading a suite across machines — is handled by Playwright natively. The --workers flag runs tests in parallel on one machine, and --shard=1/4 splits the suite across runners in CI. That's zero extra infrastructure to run, patch, and keep version-matched.

CI: sharded parallel runs on every PR

Put it together and you get sharded parallel runs on every pull request. This GitHub Actions workflow fans the suite out across four runners and uploads each shard's report, so a full end-to-end pass finishes in a fraction of the time.

GitHub Actions — .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    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 --shard=${{ matrix.shard }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report-${{ strategy.job-index }}
          path: playwright-report/

Migration pitfalls we've seen

  • Porting brittle XPath and CSS selector habits over instead of adopting role- and label-based locators — the selector habits are usually the real maintenance cost, not the framework.
  • Leaving page.waitForTimeout() sleeps in "just to be safe," which reintroduces the exact flakiness Playwright's auto-waiting exists to remove.
  • Trying to migrate the entire suite in one shot instead of running both frameworks in CI side-by-side for a transition window.
  • Not budgeting time to rebuild Page Object classes properly, and settling for literal find-and-replace that produces a technically-working but unmaintainable mess.
  • Assuming Cypress's cy.intercept stubs port 1:1 to Playwright's page.route() without adjusting for the different execution context.
  • Forgetting the selector-agnostic debugging wins — codegen and the trace viewer are free once you're on Playwright, but only if you actually stop reaching for console.log locator dumps.

A pragmatic decision framework

Your situation What to do
New project, no existing suite Start with Playwright directly.
Large working Selenium suite, polyglot team Stay on Selenium unless flakiness or speed is an active pain point; then pilot-migrate the worst 20 tests first.
JS/TS team on Cypress hitting multi-tab or cross-origin walls Migrate incrementally — the Playwright API will feel familiar.
Mixed team with some non-JS engineers Use Playwright's Python, .NET, and Java bindings to bridge the gap without forcing a full JS rewrite.
Team has zero automation today Start with Playwright as the default, not Selenium.

This is exactly the work our 2-week Playwright Migration Pilot does end to end — audit the existing suite, rebuild the highest-value tests in Playwright, and leave your team with the patterns and CI wiring to carry on themselves.

See the Playwright Migration Pilot →