Atsushi Yamamoto

Cypress vs Playwright: Architecture deep dive

While researching the differences between Cypress and Playwright, I was surprised by the lack of articles covering their architectures. Most of the articles were either not in-depth enough or slightly outdated. This is my attempt to document what I learned. We’ll cover Cypress first, then Playwright, then compare both in terms of performance and flakiness.

Cypress architecture

cypress architecture

Cypress has three major components (based on v15.13.0):

Cypress server and proxy are part of the Electron app (which was surprising to me, but here’s the GitHub issue about it). On browser start, the server sets the proxy via the browser configuration. The --proxy-server flag is used in Chrome and session.SetProxy is used for Electron.

The server uses SQLite to store state. This includes both cy.session() and creating a separate .db file per test suite.

Demo: Cypress Electron

For example, when you run npx cypress open, it opens up a new Cypress launcher. This is the Cypress Electron app (which also hosts Node.js server and proxy):

cypress electron launcher

Test driver & AUT

After Cypress app initializes in the browser, it loads two iframes:

  1. Test driver: Executes the cy test code
  2. App under test (AUT): User application being tested.

Cypress internally uses Mocha as the test runner (where .describe() and .it() API are from). For element selectors, it uses jQuery.

Although the Test driver iframe runs the test code, it isn’t executed synchronously. The test driver implements its own runtime where tasks are enqueued and executed asynchronously (this is well documented in the doc).

Why does it use iframe in the first place? From what I can find online, it’s a product of history. Selenium 1 originally used a similar iframe injection architecture, but migrated to using WebDriver Classic (predecessor of CDP - Chrome DevTools Protocol) in Selenium 2. WebDriver Classic serialized every DOM interaction into an HTTP request/response cycle, which was a performance bottleneck. Cypress extended the original <iframe> architecture with a goal to reduce check-and-act cycle latency.

Normally, iframes with different origins can’t interact with each other due to the same-origin policy. Cypress works around this by overwriting the origin of all three windows to be the same: app, test driver, and AUT.

Demo: cy.visit()

Here’s a simple Cypress test that visits google.com:

describe('Cypress demo', () => {
  it('passes', () => {
    cy.visit('https://google.com')
  })
})

When I run it locally within the Cypress App, here’s the output: cypress visit demo

All windows share the same origin:

// In Chrome Dev Tools
// ----------------------------
console.log(window.location.origin)
// 'https://www.google.com' (Cypress App)
document.querySelectorAll('iframe').forEach((iframe) => {
    console.log(iframe.contentWindow.location.origin)
})
// 'https://www.google.com' (AUT)
// 'https://www.google.com' (Test driver)

Proxy & Protocols

All HTTP requests from the browser are intercepted by the proxy. The proxy distinguishes between the Cypress App requests and AUT requests by an X-Cypress-Is-AUT-Frame HTTP header. This enables commands like cy.intercept:

// spying and response stubbing
cy.intercept('POST', '/users*', {
  statusCode: 201,
  body: {
    name: 'Satoru Gojo',
  },
})

The way Cypress uses WebSocket and CDP is interesting but under-documented:

client.on('Fetch.requestPaused', async (params) => {
    if (await this._isAUTFrame(params.frameId)) {
      return this._continueRequest(client, params, {
        name: 'X-Cypress-Is-AUT-Frame',
        value: 'true',
      })
    }
    return this._continueRequest(client, params)
  })

Then there are operations where it uses both WebSocket and CDP. For example, when there’s a call like cy.screenshot():

  1. Test Driver dequeues the cy.screenshot() step
  2. Test Driver sends a request via WebSocket to the server
  3. Server sends a Page.captureScreenshot CDP request to the browser

Demo: Proxy

Running ps aux during a Cypress test shows Chrome with a proxy configured:

/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --proxy-server=http://localhost:50452 ... 

This port is the one the Cypress server uses:

$ lsof -i:50452 
COMMAND     PID    USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
Cypress   62976 atsushi   90u  IPv4 0xd1adfce843966189      0t0  TCP localhost:50452 (LISTEN)
$ ps -p 62976
  PID TTY           TIME CMD
62976 ttys004    0:05.51 /Users/atsushi/Library/Caches/Cypress/15.13.0/Cypress.app/Contents/MacOS/Cypress ...

Playwright Architecture

playwright architecture

Playwright’s architecture is simpler compared to Cypress (based on v1.59.1). When you’re running tests in a single machine, these are roughly the three components:

Dispatcher and Worker communication is done with the JSON-RPC over the Node.js process events. Many articles mention that Playwright uses WebSocket for worker orchestration, but this only applies to the client-server use case. Workers control browser sessions using CDP for Chrome, and Playwright’s own protocol implementations for Firefox and WebKit.

Worker both executes the test code and maintains the CDP connections. It serializes the test code to CDP as it executes sequentially via async/await.

Architecture comparisons

Performance

Many articles claim that Playwright is more performant than Cypress. Cypress has more overhead: Electron, a proxy, and iframes. It also bundles Mocha for tests, jQuery for DOM queries, Vue.js for the in-browser Cypress App, Bluebird for Promises, Webpack, and probably more. The Cypress App is rendered in headless mode unless you disable it with --no-runner-ui.

Playwright executes much less JavaScript on top of relying entirely on CDP for browser interactions. The biggest performance gain would come from sharding & single-machine parallelization (using BrowserContext and storageState).

Flakiness

Many articles claimed that Playwright is less flaky, but this is more of a nuanced discussion. Cypress has the retry-ability feature, but so does Playwright. Similarly, Playwright’s auto-wait gets lots of praise, but Cypress also has a similar ensure feature in the code. In general, the real risk of flakiness depends mostly on how the test code is written.

With that being said, debugging flaky tests seems substantially easier with Playwright. It can produce a self-contained .zip trace file capturing network, console, and DOM snapshots that AI can consume easily. This would be helpful for investigating flaky CI test cases, particularly with tools like its MCP server and CLI being available.

Conclusion

For E2E testing, Playwright seems like a better fit for my use cases. Cypress’s iframe and command-queue model predates async/await, and it prioritizes interactive debugging over headless CI. The divergence also feels like a difference in product strategy since many useful features are only available through their Cloud service, whereas Playwright is fully open-source.

This research took around a week, but I have a much better clarity on the similarities and differences. Let me know if you found this useful or noticed something wrong!


Resources