
Next.js instrumentation-client.js
Server-side instrumentation gets you visibility into requests, renders, and errors that happen before HTML ever reaches a browser. But a meaningful chunk of what actually determines whether an app feels fast — client-side navigation timing, JavaScript errors that only manifest in specific browsers, the gap between "the page loaded" and "the page is actually interactive" — never touches your server at all. instrumentation-client.js is the convention for that other half: code that runs in the browser, before your app hydrates, specifically so your monitoring is already listening by the time anything worth measuring happens.
File Location and Basic Usage
Place instrumentation-client.ts (or .js) in the root of your project, or inside src if you're using that layout — the same top-level location as instrumentation.js, though these two files run in completely different environments and serve different purposes (this one is browser-only; instrumentation.js is server-only).
Unlike server-side instrumentation, there's no required function export here. You can write monitoring setup directly at the top level of the file:
// Set up performance monitoring
performance.mark("app-init");
// Initialize analytics
console.log("Analytics initialized");
// Set up error tracking
window.addEventListener("error", (event) => {
reportError(event.error);
});
The docs explicitly recommend wrapping your instrumentation code in try/catch blocks. This matters more than it sounds like it should: a bug in your monitoring setup shouldn't be able to take down or corrupt monitoring for other, unrelated concerns running in the same file. Isolate failures so a broken analytics integration doesn't also silently disable your error tracking.
Tracking Router Navigations
The one function this file does support, if you export it, is onRouterTransitionStart — a hook into the start of every App Router client-side navigation:
export function onRouterTransitionStart(
url: string,
navigationType: "push" | "replace" | "traverse",
) {
console.log(url, navigationType);
}
By default you get the destination URL and the navigation type. There's a richer, currently experimental event payload available if you opt in via next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
instrumentationClientRouterTransitionEvents: true,
},
};
export default nextConfig;
With that flag enabled, onRouterTransitionStart receives a third argument carrying meaningfully more context:
import type { RouterTransitionStartEvent, RouterTransitionType } from "next";
export function onRouterTransitionStart(
url: string,
navigationType: RouterTransitionType,
{ id, timestamp, fromRoutes, prefetchIntent }: RouterTransitionStartEvent,
) {
console.log(id, timestamp, url, navigationType, fromRoutes, prefetchIntent);
}
Each field earns its place:
id— an opaque identifier shared across every event tied to this one transition, letting you stitch together a single navigation's full lifecycle even if multiple events fire for it.timestamp— a framework-captured Unix timestamp, not one you have to record yourself the instant this callback fires (which would already be measuring slightly late).fromRoutes— the route patterns visible immediately before this navigation. The primarychildrenroute comes first, followed by any parallel slots in deterministic order — genuinely useful if your app uses Parallel Routes and you want to know exactly what was on screen before the user moved away from it.prefetchIntent— for link-driven navigations, whether the clicked<Link>requested full prefetching (full), used the automatic default (auto), or opted out (none). For anything without an associated link — a programmaticrouter.push(), or browser back/forward — this isnull, since there's no link prefetch intent to report.
One detail that matters for building dashboards off this data: route entries follow filesystem-style patterns, not literal URLs. Navigating away from /blog/hello-world reports /blog/[slug], not the specific slug — which is exactly what you want if you're aggregating navigation timing by route type rather than drowning in one row per unique post.
Errors thrown inside your onRouterTransitionStart hook are isolated — a bug in your instrumentation code doesn't block the navigation itself or break other hooks running alongside it.
Execution Timing — the Part That Actually Matters
This is the section worth reading most carefully, because getting the timing model wrong is the single most common way people misuse this file. instrumentation-client.js executes at a precise point in the page lifecycle:
- After the HTML document has loaded.
- Before React hydration begins.
- Before any user interaction is possible.
That window — after the document exists, before the app becomes interactive — is exactly why this file is the right place for error tracking and performance marks that need to capture the earliest moments of a page's life, moments that would otherwise be invisible to instrumentation set up inside your React components (which by definition can't run before hydration starts).
But there's a sharp edge here: only synchronous, top-level code is guaranteed to run before hydration begins. Anything asynchronous — a Promise, a dynamic import(), a top-level await — is not awaited by the framework. It's fire-and-forget. It may well resolve after hydration has already started, which defeats the entire purpose of putting it in this file in the first place if what you needed was a guarantee of "before."
Next.js also actively watches your instrumentation's own initialization cost: in development, it logs a warning if this file takes longer than 16ms to run, since that's roughly the frame budget where "smooth" page loading starts to visibly degrade. Keep this code genuinely lightweight — it's not the place for heavy synchronous computation.
Polyfills: Where the Async Gotcha Actually Bites
The async-timing caveat above isn't just theoretical — it has one very concrete failure mode: polyfills. If you need a feature detected and polyfilled before your components run, a conditional dynamic import here will not reliably deliver on that:
// Avoid: the dynamic import is fire-and-forget, so `ResizeObserver`
// may still be undefined when your components run.
if (!window.ResizeObserver) {
import("./lib/polyfills/resize-observer").then((mod) => {
window.ResizeObserver = mod.default;
});
}
That .then() callback might not fire until well after hydration — meaning any component that checks for window.ResizeObserver during its first render could easily find it still undefined. The correct pattern is a static import combined with synchronous feature detection:
import ResizeObserverPolyfill from "./lib/polyfills/resize-observer";
if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserverPolyfill;
}
The tradeoff, worth being explicit about: because the import is static, the polyfill code ships in the bundle to every visitor, whether or not they actually need it. For a polyfill that's only relevant to a small slice of your traffic (older browsers, specific feature gaps), and where "before hydration" isn't a hard requirement, it's usually better to polyfill lazily inside the specific component that uses the feature, rather than paying the bundle-size cost for everyone up front just to get a synchronous-import guarantee you don't actually need at that call site. Also worth knowing: Next.js already injects a baseline of widely-needed polyfills (fetch, URL, Object.assign, and similar) automatically for browsers that need them — you only need to hand-roll anything beyond that baseline.
How This Interacts With next.config.js Plugins
If you use a config-wrapping plugin like withSentry or similar observability wrappers, be aware that these can register their own client instrumentation module via the instrumentationClientInject config option. Modules registered this way run before your own instrumentation-client.js, in the array order you configure, and they can export the same onRouterTransitionStart hook independently of yours. Your application code should keep using this file convention directly rather than trying to route everything through a plugin's injection mechanism — the two systems are designed to compose, not compete.
Comparing to instrumentation.js
instrumentation.js | instrumentation-client.js | |
|---|---|---|
| Runs in | Server (Node.js or Edge) | Browser |
| Required export | register() (optional but conventional) | None — top-level code runs directly |
| Special hook | onRequestError() | onRouterTransitionStart() |
| Timing guarantee | Completes before server accepts requests | Runs after document load, before hydration (sync code only) |
| Typical use | Tracing SDKs, server error reporting | Analytics init, error tracking, performance marks, polyfills |
They're complementary halves of a full observability setup, not alternatives to each other — most production apps that care about this at all end up using both.
Version History
| Version | Changes |
|---|---|
v16.3.0 | Experimental router transition start event introduced |
v15.3 | instrumentation-client introduced |
Key Takeaways
| Rule | Why it matters |
|---|---|
| No required export | Write monitoring code at the top level; only export onRouterTransitionStart if you need it |
| Runs before hydration | Ideal for early error tracking and performance marks — but only synchronous top-level code is guaranteed to complete in time |
| Async work is fire-and-forget | Don't rely on a Promise or dynamic import() finishing before your components run |
| Polyfills need static imports | A conditional dynamic import can resolve too late; use synchronous feature detection with a static import instead |
| Keep it lightweight | Next.js warns in dev if this file takes over 16ms to execute |
| Route data is pattern-based | fromRoutes reports filesystem-style patterns like /blog/[slug], not literal URLs |
instrumentation-client.js earns its place in the App Router's file-convention vocabulary by solving one specific, narrow problem well: guaranteeing your client-side observability is listening before your app becomes interactive, not scrambling to catch up afterward. Respect the synchronous-code timing guarantee, and it does exactly what it promises.


