skip to content

How do you feature-detect the Navigation API in a browser, and what do you fall back to when it is unavailable?

level: juniorimportance: should knowfreq 28%

answer

  1. check the global, not a method
  2. window.navigation, not navigator
  3. one branch at startup
  4. the old path is the fallback
  5. never run both engines at once

basics

~20 s

Test for the object itself with 'navigation' in window before using it. When it is missing, fall back to the History API path: pushState plus a popstate listener, with your own click and submit interception.

solid answer

~50 s

The check is `if ("navigation" in window)` — the Navigation API is exposed as `window.navigation`, so testing for that property is enough, and it is cheap. Do it once at startup and route your router through one of two implementations. When the object is present you attach a single `navigate` listener and call `event.intercept()`. When it is absent you use the older path: capture link clicks and form submissions yourself, update the URL with the History API, and listen for `popstate` to react to back and forward. Two traps are worth calling out. First, do not confuse `window.navigation` with `window.navigator` or with the legacy `performance.navigation` object — different, unrelated things with confusingly similar names. Second, never run both paths at once; if a `navigate` listener and a document-level click handler are both live you will handle the same navigation twice.

code

javascript · 37 lines
javascript
function createNavigationApiRouter() {
  return {
    start() {
      window.navigation.addEventListener("navigate", (event) => {
        if (!event.canIntercept) return;
        event.intercept({ handler: () => render(event.destination.url) });
      });
    },
    go(url) {
      window.navigation.navigate(url);
    },
  };
}

function createHistoryRouter() {
  return {
    start() {
      window.addEventListener("popstate", () => render(location.href));
      document.addEventListener("click", onLinkClick, true);
    },
    go(url) {
      history.pushState(null, "", url);
      render(url);
    },
  };
}

function onLinkClick(event) {
  /* filter modifier keys, targets, cross-origin, then preventDefault */
}

function render(url) {
  document.querySelector("#app").textContent = new URL(url, location.href).pathname;
}

const router = "navigation" in window ? createNavigationApiRouter() : createHistoryRouter();
router.start();

go deeper

for a junior

Know the one-line check, "navigation" in window, and be able to say that the older History API approach is what runs when the check fails.

for a middle

Explain how you keep the two paths from overlapping: one branch at startup behind a shared router interface, never both listeners live at once.

for a senior

Discuss owning the fallback as real code — the click filtering, focus and scroll work the API does for free — and the telemetry that tells you when it is safe to delete.

for a principal

Frame the branch as a deliberate, deletable seam with a written exit criterion, so the team is not maintaining two navigation engines indefinitely.

## The check itself ```js if ("navigation" in window) { // Navigation API available } else { // History API fallback } ``` The Navigation API is exposed as a single global object, `window.navigation`, of interface type `Navigation`. Because everything else in the API hangs off that object — the `navigate` event, `entries()`, `currentEntry`, `navigate()`, `traverseTo()` — one property check gates all of it. There is no need for finer-grained detection of individual methods in the common case, though an option added later (such as the `commit` option on `intercept()`) may still be worth probing separately if you depend on it. Why `in` rather than `window.navigation !== undefined`? Either works. `in` reads as "does the platform expose this" and avoids the habit of touching a possibly-undefined global, which matters more when the same module also runs somewhere without a `window` at all. ## Names that are not the Navigation API Three similar-looking globals trip people up: - `window.navigator` — the long-standing object with `userAgent`, `language`, `sendBeacon` and friends. Nothing to do with navigation control. There is no `navigator.navigation`. - `performance.navigation` — a legacy, deprecated object describing how the *current document* was reached (reload, back/forward, normal). Superseded by navigation timing entries. Detecting it tells you nothing about the Navigation API. - `window.navigation` — the Navigation API. This is the one. If you write your check against the wrong one it will silently take the wrong branch on every browser, and the bug is invisible in the browser where the API does work. ## What the fallback path is The pre-Navigation-API way of doing client-side routing is still the fallback, and it is not one API but three pieces working together: 1. **Intercept the user's intent yourself.** A capturing `click` listener on `document` that finds the enclosing anchor and bails out on modifier keys, non-primary buttons, `target` attributes, `download` links and cross-origin hrefs. A `submit` listener if you also route form submissions. 2. **Change the URL** with the History API's `pushState`/`replaceState`. 3. **React to traversals** with a `popstate` listener, since programmatic pushes do not fire it but back and forward do. Anything the Navigation API did for you — focus reset, scroll restoration, knowing when the transition finished — you now do by hand. That asymmetry is the real cost of supporting both paths, and it is why the fallback should be written as an implementation of the same small interface, not sprinkled through the app. ## Structuring the two paths ```js const router = "navigation" in window ? createNavigationApiRouter() : createHistoryRouter(); router.start(); ``` One branch, one place, taken at startup. The rest of the application calls `router.go(url)` and never asks which engine is underneath. That gives you a single seam to delete later, when support is broad enough to drop the fallback, and it stops the double-handling bug: with both engines live, a link click is caught by your capturing click handler *and* surfaces as a `navigate` event, so the route renders twice or the URL ends up pushed twice. ## Server-side rendering and build-time code If the same module is imported by a server renderer, `window` itself may not exist, so guard the whole branch behind a check that the document environment is present before touching `window.navigation`. Feature detection at module scope in shared code is a common source of crashes that only appear in the server build. ## Why this is asked It is a quick signal for two habits: detecting capabilities rather than sniffing user agents, and thinking about the fallback before the shiny path. A candidate who answers only "check if window.navigation exists" has half the answer; the other half is what the application does when it does not.

  • Why is running the Navigation API listener and the old click-interception path at the same time a bug?
    Because a single link click reaches both. Your capturing click handler calls `preventDefault()` and renders, and the `navigate` event fires for the navigation you then perform, so the route renders twice or the history entry is pushed twice. The two engines must be mutually exclusive, chosen once at startup.
  • What is `performance.navigation`, and why is it not a valid way to detect the Navigation API?
    It is a legacy, deprecated object describing how the current document was loaded — reload, back/forward, or a normal navigation — and it exists in browsers that have never shipped the Navigation API. Detecting it would take the wrong branch everywhere. The only correct check is for `window.navigation` itself.

saying these in an interview costs you the question

  • Detects with navigator.navigation, which does not exist
  • Sniffs the user agent string instead of checking the global
  • Assumes no fallback is needed because 'most users are on Chrome'
  • Leaves both routing engines active at the same time
  • Touches window.navigation at module scope in server-rendered code

context