Product
rum
datadog/browser-sdk
datadog/browser-rum-nextjs
Environment
@datadog/browser-rum : 6.33.0
@datadog/browser-rum-nextjs : 6.33.0
next : 16.1.6 (App Router)
react/react-dom : 19.2.4
reactStrictMode : false
Describe the bug
DatadogAppRouter from @datadog/browser-rum-nextjs calls startNextjsView() during the render phase, and relies on a useRef guard alone to prevent duplicates.
export function DatadogAppRouter() {
const pathname = mockable(usePathname)()
const params = mockable(useParams)()
const previousPathname = mockable(useRef)<string | null>(null)
if (previousPathname.current !== pathname) { // reading a ref during render
previousPathname.current = pathname // writing a ref during render
startNextjsView(computeViewNameFromParams(pathname, params)) // side effect during render
}
return null
}
React is free to discard a render that never commits and start over from scratch. When that happens, a component that has not committed yet gets a brand-new useRef along with its new fiber, so the guard is reset — but the startView() call has already escaped and cannot be undone.
The guard rolls back. The side effect does not.
The result is one duplicate RUM view per discarded mount render.
Because nextjsPlugin sets initConfiguration.trackViewsManually = true in onInit, there is no dedupe layer underneath — one startView() call is one real, billable view.
This is documented usage, not misuse
The "App router usage" section of packages/browser-rum-nextjs/README.md instructs users to render DatadogAppRouter from the root layout:
- Call the DatadogAppRouter component from your root layout.
// app/layout.tsx — from the README
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<DatadogAppRouter />
{children}
</body>
</html>
)
}
Our setup matches this example exactly, so the bug reproduces on the documented installation path.
To Reproduce
- Set up a Next.js App Router app with nextjsPlugin() and .
- Render the following probe right next to (same parent, adjacent position):
'use client'
import { useEffect, useRef } from 'react'
let renderSeq = 0 // module scope — unaffected when a render is discarded
export const RestartProbe = () => {
const instanceId = useRef<number | null>(null)
if (instanceId.current === null) {
renderSeq += 1 // only increments for a brand-new fiber
instanceId.current = renderSeq
}
console.log(`[render] instance=${instanceId.current} t=${Math.round(performance.now())}`)
useEffect(() => {
console.log(`[commit] instance=${instanceId.current}`)
}, [])
return null
}
renderSeq lives in module scope, so discarding a render does not reset it. useRef, on the other hand, is recreated whenever a render is discarded before committing. That means an increasing instance number is a direct count of how many times the mount render was retried on a new fiber — which is exactly how many times DatadogAppRouter's previousPathname guard was reset.
- Reload the page and check the console.
Measured output (single page reload)
[render] instance=1 t=2315
[render] instance=2 t=2357
[render] instance=3 t=2364
[render] instance=4 t=2372
[render] instance=5 t=2380
[render] instance=6 t=2386
[commit] instance=6
What this shows:
- 6 mount renders, each on a different fiber (instance goes 1 → 6)
- 1 commit, on instance=6 — only the last attempt committed; the first 5 were discarded
- instances 2 through 6 all happened within 29ms (t=2357 → 2386)
DatadogAppRouter sits in the same position and goes through the same 6 mount renders. Its useRef guard is recreated as null every time, so startNextjsView() fires 6 times — 5 of them from renders that never made it to the screen.
In the same session, the RUM dashboard showed the same view count increasing repeatedly for /[locale]/videos/[id].
Expected behavior
A single page reload should create exactly one view.
startNextjsView() should only run from a committed render. A discarded render must not create a view.
Product
rumdatadog/browser-sdkdatadog/browser-rum-nextjsEnvironment
@datadog/browser-rum: 6.33.0@datadog/browser-rum-nextjs: 6.33.0next: 16.1.6 (App Router)react/react-dom: 19.2.4reactStrictMode: falseDescribe the bug
DatadogAppRouter from @datadog/browser-rum-nextjs calls startNextjsView() during the render phase, and relies on a useRef guard alone to prevent duplicates.
React is free to discard a render that never commits and start over from scratch. When that happens, a component that has not committed yet gets a brand-new useRef along with its new fiber, so the guard is reset — but the startView() call has already escaped and cannot be undone.
The guard rolls back. The side effect does not.
The result is one duplicate RUM view per discarded mount render.
Because nextjsPlugin sets initConfiguration.trackViewsManually = true in onInit, there is no dedupe layer underneath — one startView() call is one real, billable view.
This is documented usage, not misuse
The "App router usage" section of packages/browser-rum-nextjs/README.md instructs users to render DatadogAppRouter from the root layout:
Our setup matches this example exactly, so the bug reproduces on the documented installation path.
To Reproduce
renderSeq lives in module scope, so discarding a render does not reset it. useRef, on the other hand, is recreated whenever a render is discarded before committing. That means an increasing instance number is a direct count of how many times the mount render was retried on a new fiber — which is exactly how many times DatadogAppRouter's previousPathname guard was reset.
Measured output (single page reload)
What this shows:
DatadogAppRouter sits in the same position and goes through the same 6 mount renders. Its useRef guard is recreated as null every time, so startNextjsView() fires 6 times — 5 of them from renders that never made it to the screen.
In the same session, the RUM dashboard showed the same view count increasing repeatedly for
/[locale]/videos/[id].Expected behavior
A single page reload should create exactly one view.
startNextjsView()should only run from a committed render. A discarded render must not create a view.