What you'll learn
Quick Answer
Selenium is the oldest and most portable - it automates any major browser from Java, Python, C#, Ruby, JavaScript, and more, via the W3C WebDriver protocol. Cypress runs your test code inside the browser alongside your app, which makes debugging pleasant but limits it to what a page can do. Playwright drives Chromium, Firefox, and WebKit from a Node process using each browser's automation protocol, with fast auto-waiting and strong parallelism. For a new JavaScript or TypeScript project, Playwright is the usual pick.
Three different architectures
The tools differ most in where the test code runs relative to the browser, and almost everything else follows from that.
Selenium speaks the W3C WebDriver protocol to a browser-specific driver (chromedriver, geckodriver) which controls a real, unmodified browser. Your test is an ordinary program in whatever language you like, sending commands over HTTP to the driver. Maximum portability, but each command is a round-trip and the driver layer adds latency and version-matching chores.
Cypress loads your test code into the browser itself and runs it in the same event loop as your application. It can reach into your app's internals, stub network requests at the fetch layer, and give you time-travel debugging. The cost: it is fundamentally a browser page, so multi-tab, multi-origin, and multi-browser scenarios are awkward or unsupported, and it is JavaScript-only.
Playwright runs your test in a Node process and drives the browser from outside using the browser's own automation protocol (Chrome DevTools Protocol and equivalents). It bundles patched builds of Chromium, Firefox, and WebKit. This gives real cross-browser coverage, true parallel isolation, and low-latency control, without running inside the page.
Browser and platform support
Selenium covers the widest matrix: Chrome, Firefox, Edge, Safari, and historically Internet Explorer, on desktop, plus real mobile browsers through Appium. If you must test Safari on actual macOS or a specific legacy browser, Selenium is often the only option.
Playwright tests Chromium, Firefox, and WebKit. WebKit is the engine behind Safari, so Playwright gives you a close approximation of Safari behavior on Linux CI - but it is Playwright's WebKit build, not Safari itself, so a small set of Safari-specific quirks will not show up.
Cypress runs in Chrome-family browsers, Firefox, and Electron. It added WebKit support as an experimental option. In practice most teams run Cypress against Chrome.
For a public site where Safari share matters and you need certainty, Selenium against real Safari (or a device cloud) is the safe answer. For most apps, Playwright's three engines are enough signal.
Auto-waiting and flakiness
The classic source of flaky E2E tests is acting on an element before it is ready. How each tool handles this is a real differentiator.
Selenium historically made you manage waits yourself - explicit WebDriverWait with expected conditions. Newer versions and wrappers improved this, but a lot of Selenium suites still carry hand-written waits and the occasional sleep().
Cypress retries commands and assertions automatically until they pass or time out. cy.get('.item').should('be.visible') keeps re-checking. This is one of Cypress's best features.
Playwright auto-waits on every action: before a click it waits for the element to be attached, visible, stable, and able to receive events. Its locators are lazy and re-resolve each time, so a locator captured before a re-render still points at the right element. In a quick test, an assertion like expect(locator).toBeVisible() transparently waited through a 400ms delayed DOM update and passed without any explicit wait.
Language options and developer experience
Selenium is the only one of the three with first-class bindings in Java, Python, C#, Ruby, and JavaScript. If your team is a Java or .NET shop and wants tests in the same language as the code, Selenium is the natural fit.
Cypress is JavaScript and TypeScript only. Its runner UI - a browser window showing every command, with DOM snapshots you can step back through - is genuinely excellent for writing and debugging tests.
Playwright has official bindings in JavaScript/TypeScript, Python, Java, and C#, though the JS/TS experience is the most complete. It ships codegen (record actions into a test), a trace viewer (a full timeline with DOM snapshots, network, and console for a failed run), and a UI mode similar to Cypress's. Its parallelism is built in - tests shard across workers with isolated browser contexts by default.
The in-browser gotcha
Cypress's biggest limitation flows directly from running inside the browser: it cannot easily do things a single page cannot do. Testing a flow that opens a new tab, a login that redirects across origins, an OAuth popup, or a download that triggers an OS dialog - all of these are historically painful or need workarounds in Cypress. Cypress has added multi-origin and tab support, but it remains against the grain of the design.
Playwright and Selenium drive the browser from outside, so multiple tabs, multiple origins, popups, downloads, and even multiple independent browser contexts in one test are normal operations.
If your critical user journeys involve third-party auth, payment redirects, or multi-window flows, verify how your candidate tool handles them before committing - this is where teams get stuck six months in.
Which to choose
Playwright is the default recommendation for a new JS/TS project: fast, real cross-browser, great debugging tools, built-in parallelism, and it handles multi-tab and multi-origin flows cleanly.
Cypress suits teams who value its authoring and debugging UX above all, whose app is a single-origin SPA without heavy redirect flows, and who are all-in on JavaScript.
Selenium is right when you need a language other than JavaScript, must test real Safari or a legacy browser, need real-device mobile via Appium, or already have a large working Selenium suite and a team that knows it.
All three can produce reliable suites. The flakiness people blame on the tool is usually missing waits, shared test state, or environment differences between local and CI - fix those first, whatever you pick.
