# How Cypress Handles Navigation Events and Detects Page Load Transitions: A Deep Dive into the Driver Layer

> Discover how Cypress handles navigation events and detects page load transitions. Learn about the driver layer's event monitoring and state management for seamless testing.

- Repository: [Cypress.io/cypress](https://github.com/cypress-io/cypress)
- Tags: deep-dive
- Published: 2026-07-12

---

**Cypress monitors browser navigation through the driver layer in [`packages/driver/src/cy/commands/navigation.ts`](https://github.com/cypress-io/cypress/blob/main/packages/driver/src/cy/commands/navigation.ts), which hooks into `navigation:changed` and `stability:changed` events to track URL changes, manage page-loading states, and resolve when the browser fires `window:load`, `download:received`, or `internal:window:load`.**

Cypress navigation events form the backbone of how the testing framework knows when a page has started loading and when it has fully stabilized. Understanding how Cypress detects page load transitions is essential for debugging flaky tests and writing reliable end-to-end automation. This article examines the source code in the `cypress-io/cypress` repository to reveal exactly how the driver consumes browser events and manages the navigation lifecycle.

## Event Registration and Global Listeners

When the driver boots, it attaches listeners to Cypress’ global event bus inside the default export function of [`packages/driver/src/cy/commands/navigation.ts`](https://github.com/cypress-io/cypress/blob/main/packages/driver/src/cy/commands/navigation.ts) (lines 73–84). The driver registers two critical subscriptions:

- `Cypress.on('navigation:changed', …)` triggers the `navigationChanged` callback (lines 78–81)
- `Cypress.on('stability:changed', …)` routes through `stabilityChanged` to invoke `pageLoading` (lines 73–77)

These hooks bridge raw browser signals with Cypress’ internal state machine, allowing the framework to react to URL changes and stability fluctuations.

## Detecting Navigation Changes

The **`navigationChanged`** function (lines 62–94) executes whenever the browser URL updates. It compares the current location—retrieved via `cy.getCrossOriginRemoteLocation()`—against the previously stored value in `state('url')`. When a mismatch occurs, the driver:

1. Updates **`state('url')`**, **`state('urls')`**, and **`state('urlPosition')`** to reflect the new history entry
2. Emits the public **`cy:url:changed`** event for user-level listeners
3. Logs a "new url" entry unless the navigation was triggered internally by a Cypress command

This mechanism ensures that every genuine navigation is recorded, including cross-origin transitions.

## Page‑Loading vs. Page‑Loaded State

Cypress distinguishes between *unstable* (loading) and *stable* (loaded) states through the **`stabilityChanged`** handler. When the test becomes unstable (`stable === false`), the driver flips the `pageLoading` flag via `pageLoading(!stable, …)` (lines 78–82).

The flag persists in **`state('pageLoading')`** and propagates to the application layer through `Cypress.action('app:page:loading', bool)` (lines 75–76). This allows the command queue, UI, and logs to synchronize their behavior while waiting for network activity and DOM readiness to settle.

## Waiting for Full Load Completion

The **`pageLoading`** helper creates a log entry (`Cypress.log({ name: 'page load', … })`) and returns a promise that resolves only when the browser confirms the load is finished. According to the source code in [`packages/driver/src/cy/commands/navigation.ts`](https://github.com/cypress-io/cypress/blob/main/packages/driver/src/cy/commands/navigation.ts) (lines 99–106), the driver listens for three distinct events:

- **`window:load`** – Standard navigation completion
- **`download:received`** – File download initiated (treated as unload)
- **`internal:window:load`** – Cross‑origin success or failure

The promise is wrapped with `Promise.timeout`. If the timeout expires before any of these events fire, **`timedOutWaitingForPageLoad`** throws the **`navigation.timed_out`** error (lines 41–51).

## Special Cases and Edge Handling

### Hash‑Only Changes

Hash‑only navigation avoids full network requests. The driver detects this scenario in lines 87–95 and immediately swaps the iframe `src`, resolving the load as soon as the **`hashchange`** event fires rather than waiting for a full page load cycle.

### Cross‑Origin Failures

When a cross‑origin request fails, the **`onCrossOriginFailure`** handler (lines 52–56) records the error on the log and still resolves the promise, preventing the test from hanging indefinitely while preserving the error state for debugging.

### Redirect Limits

To prevent infinite redirect loops, the `onWindowLoad` callback enforces the configured **`redirectionLimit`**. Exceeding this limit triggers the **`navigation.reached_redirection_limit`** error (lines 25–34), halting the test with a clear diagnostic message.

## Code Examples

```javascript
// Listen to navigation changes (e.g. when cy.visit() loads a new page)
cy.on('navigation:changed', (source, args) => {
  // `source` is the Cypress command that caused the navigation
  // `args` may contain additional info such as the target URL
  console.log('Navigated:', args?.url ?? source)
})

// Detect when Cypress thinks the page is loading/unloading
cy.on('app:page:loading', (isLoading) => {
  console.log('Page loading?', isLoading)
})

// Example: using cy.visit() which internally triggers the above machinery
cy.visit('/my-page', {
  onBeforeLoad (win) { console.log('before load', win.location.href) },
  onLoad (win) { console.log('after load', win.location.href) },
})

// Example: force a reload and wait for the page‑load event to finish
cy.reload().then(() => {
  // The page load has completed (or timed out)
  cy.log('Reload finished')
})

// Example: go back/forward in history – the driver sets `knownCommandCausedInstability`
cy.go('back')

```

## Summary

- **Entry point**: The driver registers listeners in [`packages/driver/src/cy/commands/navigation.ts`](https://github.com/cypress-io/cypress/blob/main/packages/driver/src/cy/commands/navigation.ts) (lines 73–84) during bootstrap to hook `navigation:changed` and `stability:changed` events.
- **Change detection**: The `navigationChanged` function compares `cy.getCrossOriginRemoteLocation()` against stored state and emits `cy:url:changed` when the URL differs.
- **Loading state**: `stabilityChanged` toggles the `pageLoading` flag stored in `state('pageLoading')` and broadcasts `app:page:loading` to the rest of the framework.
- **Resolution**: The load promise resolves on `window:load`, `download:received`, or `internal:window:load`, with a timeout that throws `navigation.timed_out` if exceeded.
- **Edge cases**: Hash‑only updates resolve on `hashchange`, cross‑origin failures are captured without hanging, and redirects respect the `redirectionLimit` to prevent infinite loops.

## Frequently Asked Questions

### How does Cypress detect when a page starts loading?

Cypress detects loading starts through the `stability:changed` event. When the browser becomes unstable (e.g., during a navigation request), the `stabilityChanged` handler in [`packages/driver/src/cy/commands/navigation.ts`](https://github.com/cypress-io/cypress/blob/main/packages/driver/src/cy/commands/navigation.ts) invokes `pageLoading(!stable, …)`, which sets `state('pageLoading')` to `true` and emits `Cypress.action('app:page:loading', true)` to notify the command queue and UI.

### What browser events signal that a page has finished loading?

According to the source code in [`packages/driver/src/cy/commands/navigation.ts`](https://github.com/cypress-io/cypress/blob/main/packages/driver/src/cy/commands/navigation.ts) (lines 99–106), Cypress listens for three specific events to resolve the load promise: `window:load` for standard navigation, `download:received` when a file download interrupts the page, and `internal:window:load` for cross‑origin transitions. The first event to fire resolves the waiting promise.

### How does Cypress handle hash‑only URL changes?

Hash‑only changes are treated as navigation without network requests. The driver detects this scenario in lines 87–95 of [`packages/driver/src/cy/commands/navigation.ts`](https://github.com/cypress-io/cypress/blob/main/packages/driver/src/cy/commands/navigation.ts) and immediately swaps the iframe `src`. It resolves the navigation as soon as the native `hashchange` event fires, bypassing the full page‑load waiting logic.

### What happens when a page load times out in Cypress?

If none of the completion events (`window:load`, `download:received`, or `internal:window:load`) fire within the configured timeout, the `timedOutWaitingForPageLoad` function (lines 41–51) throws the `navigation.timed_out` error. This error bubbles up to the test runner and fails the current command with a descriptive message indicating that Cypress waited too long for the page load to complete.