How Cypress Handles Navigation Events and Detects Page Load Transitions: A Deep Dive into the Driver Layer
Cypress monitors browser navigation through the driver layer in 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 (lines 73–84). The driver registers two critical subscriptions:
Cypress.on('navigation:changed', …)triggers thenavigationChangedcallback (lines 78–81)Cypress.on('stability:changed', …)routes throughstabilityChangedto invokepageLoading(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:
- Updates
state('url'),state('urls'), andstate('urlPosition')to reflect the new history entry - Emits the public
cy:url:changedevent for user-level listeners - 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 (lines 99–106), the driver listens for three distinct events:
window:load– Standard navigation completiondownload: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
// 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(lines 73–84) during bootstrap to hooknavigation:changedandstability:changedevents. - Change detection: The
navigationChangedfunction comparescy.getCrossOriginRemoteLocation()against stored state and emitscy:url:changedwhen the URL differs. - Loading state:
stabilityChangedtoggles thepageLoadingflag stored instate('pageLoading')and broadcastsapp:page:loadingto the rest of the framework. - Resolution: The load promise resolves on
window:load,download:received, orinternal:window:load, with a timeout that throwsnavigation.timed_outif exceeded. - Edge cases: Hash‑only updates resolve on
hashchange, cross‑origin failures are captured without hanging, and redirects respect theredirectionLimitto 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 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 (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 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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →