QAAll levels

Cypress

Cypress is a browser test runner that made one decision no other browser tool made: it runs the test inside the browser, in the same tab and the same event loop as the application. That is why it has the debugging experience it does — you can watch a run step by step and inspect the page at any command — and it is also why certain things are awkward or impossible. This page is about that single trade, the command queue and retry model that follow from it, and how to decide whether the trade is right for your suite.

Test runs
Inside the browser
Commands are
Enqueued, then run
Best at
Debugging a failure

The test lives in the page

Most browser automation works by remote control: the test is a program somewhere else that sends instructions to a browser and reads answers back. Cypress inverts it. Your application is loaded into an iframe, the test code is loaded beside it in the same tab, and both run on the same JavaScript event loop. There is no wire between the test and the page — the test can reach into the application the way a script on the page could.

Cypress does something no other browser tool does: it loads your application into an iframe and runs the test alongside it, in the same tab and the same event loop. That is why the debugging experience is what it is. The runner can reach into the application directly rather than asking a driver to relay a question, so it snapshots the DOM at every command and lets you scrub back through the run afterwards, and it re-runs an assertion until the page settles instead of failing the first time it looks. The same decision draws the ceiling. Anything outside the browser — reading a file, seeding a database, stubbing a network call at the process level — needs a second process that the test talks to, and anything outside the tab, such as a second browser window, an OS file dialog or a cross-origin redirect chain, is awkward at best. Neither half of that is a defect. It is one architectural choice with both of its consequences visible.

The consequence people meet first is that Cypress commands are not what they look like. A line calling for an element does not fetch it and does not return it — it appends a command to a queue that runs after the surrounding function has finished. That is why a value cannot be assigned from a command into a variable and used on the next line: at the moment the next line is being read, nothing has run yet. Everything downstream must be expressed as another command in the chain, or inside a callback that receives the result.

Retry-ability — the part that actually prevents flakiness

Cypress does not wait a fixed time for the page. It re-runs the last query in a chain together with its assertion, over and over, until the assertion passes or a timeout expires. So a check that some text has appeared after a network response needs no wait: the query re-runs against the live DOM every few milliseconds and succeeds the moment the text is there.

The rule that matters, and the one that produces most real-world flakiness, is what exactly gets retried. Only the last query before the assertion is re-run. If a chain finds a container, then finds a row inside it, then asserts, only the row lookup repeats — the container is resolved once and held. When the whole list re-renders, that held container is detached, and the test fails with an error about an element no longer in the document.

What you wroteDoes it retry?What to do instead
A query followed by an assertionYesNothing — this is the shape the model is built for.
Two queries chained, then an assertionOnly the secondUse one query that finds the element directly, so the whole lookup repeats.
A callback that inspects the resultNoMove the condition into an assertion so the retry loop can see it.
An action such as a click or a typeNot repeatedIt waits for actionability first, then runs once. Assert the resulting state, not the click.
A value read into a variable, checked laterNoThe value is frozen at read time. Assert against the live element instead.

What living in the page buys

  • Time travel

    The runner keeps a DOM snapshot for every command. Hover a step in the log and the page shows exactly what it looked like then — no reproduction, no guessing which render you are looking at.

  • Reaching into the app

    Because it shares the page, a test can call a function on the application, read its store, or spy on a method — useful for setting up state that would take six clicks to reach.

  • Network control

    Requests can be observed, waited on, delayed or replaced. Stubbing at the boundary makes an error state or an empty state trivially testable instead of impossible to arrange.

  • Component testing

    The same runner mounts a single component in a real browser. This is where most of the value is for front-end teams — it moves cases out of the slow end-to-end tier without giving up a real DOM.

The middle two deserve a warning attached. Reaching into application internals makes a test faster to write and couples it to how the feature is built rather than to what it does, so a refactor that changes nothing a user can see breaks the suite. Use it for arranging state, not for asserting behaviour. And stubbing every request produces a fast, stable suite that has stopped testing the integration — which was the reason to run a browser at all.

The ceiling, stated plainly

Living inside the tab is a genuine architectural commitment, and it has edges. None of these is a scandal; all of them are things to know before choosing, because they are not configuration problems that a later version will remove.

  • One tab. There is no second window and no popup to switch to; the usual workaround is to assert that the link would open the right place and then visit it directly.
  • Origins are a special case. Visiting a different origin inside one test requires an explicit block that runs code in that origin, which works and is not the same as simply navigating.
  • Anything outside the browser needs the Node side — reading a file, seeding a database, calling a service with credentials the browser must not hold.
  • Native browser dialogs, file pickers and OS-level interactions are outside the page and therefore outside reach.
  • Parallelism is across machines by spec file, not across contexts in one browser, and orchestrating it well is where the paid service earns its money.

Cypress is the better fit when

  • Front-end developers write and maintain the tests, and the daily experience decides whether they keep doing it.
  • Component testing in a real browser is a large part of the plan, not an afterthought.
  • The application is a single-origin web app with no multi-tab or multi-window flows.
  • A team already has a working suite, where switching tools costs more than it returns.

Reach for Playwright when

  • A test needs two users at once, or crosses origins, tabs or windows as a matter of course.
  • Suite runtime is the constraint, and cheap per-context parallelism changes the arithmetic.
  • You need the same suite across Chromium, Firefox and WebKit from one file.
  • Diagnosing CI failures without reproducing them is a priority, and a replayable trace is the mechanism.

Anti-patterns worth naming

  • Waiting a fixed number of milliseconds, which is the habit the retry model exists to make unnecessary.
  • Signing in through the interface at the start of every test, spending five seconds a case to prove something already proved once.
  • Conditional test logic based on what is on the page, which produces a test that cannot fail because it adapts to anything.
  • Stubbing every request until the suite no longer tests any integration, then reporting it as end-to-end coverage.
  • Cases that depend on state left behind by earlier cases in the same file, which pass in order and fail in parallel.

When to use it

Use it when

  • Front-end teams who will maintain the suite themselves and value the debugging experience daily.
  • Component testing in a real browser, moving cases down out of the slow end-to-end tier.
  • Single-origin web applications without multi-tab, multi-window or native dialog flows.
  • Where stubbing the network at the boundary makes error and empty states testable that otherwise are not.

Avoid it when

  • Flows that legitimately cross tabs, windows or several origins, where the workarounds outnumber the tests.
  • Suites where total runtime is the binding constraint, since parallelism is per spec file across machines.
  • Assertions written against application internals, which couple the test to how the feature is built.
  • As a reason to move logic tests into the browser, where the same coverage costs far more to run and maintain.

Found this useful?

Share it with someone who is working on the same problem.