Improving browser tracing step by step
Improving browser tracing step by step
Browser tracing has always been one of those things that feels invisible until it isn’t. When it works well, you get clear, actionable insights into how your app is performing in the wild. When it doesn’t, you’re left staring at noisy data, gaps in traces, and spans that don’t quite tell the story. Over the last few months, we’ve been chipping away at that problem.
Let’s walk through improvements we’ve made to the Sentry JavaScript SDK to make browser tracing not just more accurate, but more useful. From giving you explicit control over pageload spans, to smarter handling of redirects, to adding deeper timing data on resource spans, these updates are all about reducing the guesswork and surfacing what really matters.
Explicitly ending the pageload span
Shipped in 10.13.0
By default, our SDKs start pageload and navigation spans as idle spans. These idle spans remain active as long as child spans (e.g. fetch
request spans) are started or ended. After a defined period of inactivity the idle span ends itself.
Why are we doing it this way? Primarily because there’s no perfect indicator that universally defines when a page is “loaded”. Is it after the initial response was received, once all scripts and resources were loaded or after the application hydrated or bootstrapped itself? Sure, there are signals from the browser like domContentLoaded
or readyStateChange
but then again, it’s very likely that something still contributing to the initial perceived pageload comes afterwards. The truth is, with the variety of frameworks, web app types and general JavaScript madness out there, there’s no universal answer to this question.
Our idling and debouncing mechanism works well enough for 95% of our users and they generally can get behind the pageload duration. However, some users have been requesting a more explicit way to tell Sentry when their page was loaded. Here it is: Sentry.reportPageLoaded()
Sentry.init({
integrations: [
// 1. Enable manual pageload reporting
Sentry.browserTracingIntegration({ enableReportPageLoaded: true })
]
});
// 2. Whenever users consider the page loaded, for example:
onMounted(() => {
Sentry.reportPageLoaded();
})
Using enableReportPageLoaded: true
and Sentry.reportPageLoaded()
removes most of the idling mechanism and instead hands users full control.
Active spans outside of callbacks
Shipped in 10.15.0
With version 8 and the introduction of OpenTelemetry in our NodeJS SDKs, we traded the old transaction-focused APIs in for our new startSpan
APIs . These brought a variety of advantages (especially with Sentry moving to the span-centered tracing world) but also left a gap in browser-centered applications: It was not (easily) possible to keep a span active outside of a callback.
Active spans are spans to which you can automatically add child spans, simply by calling any startSpan
API. Inactive spans remain in the current trace but cannot automatically get assigned child spans.
Keeping a span active outside a callback was possible but it was quite hacky and hence we never recommended it officially. However, we knew this was something our users were requesting and trying to achieve themselves. Therefore, we finally added an official API: Sentry.setActiveSpanInBrowser(span)
function instrumentMyRouter() {
let routeSpan;
on('routeStart', (from, to) => {
routeSpan = Sentry.startInactiveSpan({name: `/${from} -> /${to}`});
Sentry.setActiveSpanInBrowser(routeSpan);
});
// any span started in the meantime will be a child span of `routeSpan`
Sentry.startSpan({name: 'doSomething'}, () => doSomething());
on('routeEnd', () => {
// automatically removes `routeSpan` from the scope
routeSpan.end();
});
}
Why the long name? Specifically because this API is only supported in browsers. So we want users to think twice if the call is made in the right environment. It does have its purpose though, for example for users writing their own routing instrumentation, as shown above.
Redirects vs. Navigations
A common problem we face in our SDKs is being able to differentiate between automatic client-side router redirects and user-initiated navigations. For example, React Router or Angular’s router redirecting users to a login page if they’re not authenticated, is what we consider an automatic redirect. These usually happen during an ongoing pageload or navigation, so they shouldn't start a new, disjunct span tree. In contrast, user-initiated navigations (for example clicking a link), should start a new navigation root span.
With version 9.37.0
and improvements in 10.3.0
, we introduced time- and interaction-based heuristics around which default JS History API changes should be considered redirects or navigations. We’re also working on bringing this into more framework router instrumentations but luckily some of them can already differentiate them today.
As a result, whenever we detect a redirect, the SDK now starts a navigation.redirect
child span instead of a navigation
root span:
Ignoring spans
Shipped in 9.23.0
, 9.25.0
and 10.2.0
Due to span metrics extrapolation, we had to remove certain possibilities to arbitrarily drop or remove spans before the SDK would send them to Sentry. Since v9, beforeSendSpan
only allows modification of a span but no longer permits removing it. However, users have been requesting ways to ignore certain spans, depending on pre-defined filter criteria. For example, some users consider resource.*
spans, describing the loading duration of resources like CSS or images, as low value and want to ignore them.
There are generally two ways how our SDKs deal with span ignoring options:
Add options on specific SDK integrations to ignore certain actions in the instrumentation (like resource or middleware spans).
General SDK options to “globally” ignore spans.
We initially shipped a couple of integration-based options, like ignoreResourceSpans
(shipped in 9.23.0
) or ignorePerformanceApiSpans
(shipped in 9.25.0
). However, as more users were requesting additional spans to be ignored, we introduced ignoreSpans
which is a top-level SDK option (shipped in 10.2.0
).
This option now allows users to specify patterns for spans to be ignored. It works similarly to ignoreErrors
and allows users to match spans just by span name (string or regex) or any combination of name and op
.
Sentry.init({
ignoreSpans: [
'/health',
/api\/unimportantEndpoint(\/.*)?/,
{
name: /users\/%d+/,
op: 'http.client'
}
]
})
Note: ignoreSpans
is also available in all server-side JS SDKs.
Resource span timing information
Shipped in 10.12.0
We already talked about resource.*
spans and how users sometimes perceive them as low-value. Well, ideally we can get them to change their mind by making them as useful as possible. Fortunately, browsers got us covered here, with them exposing the entire request lifecycle for all* resource requests. We now collect all available timing information for each resource span, for example the exact time it takes for domain lookup, request start time or response receiving time. We also send "Time to First Byte" for resource spans. Detailed network timing information helps users identify e.g. a slow CDN or a specific part in the request lifecycle that slows down the loading of a resource.
Since version 10.12.0
, all available request lifecycle timing information is stored as span attributes on resource spans:
* To get all timing values from cross-origin resources, the resource responses must include a matching Timing-Allow-Oirign
HTTP header. Learn more about this.
Web Vital Updates
Our SDK ships with a vendored-in version of Google’s web-vitals library which we use to calculate and listen to web vitals like LCP or CLS or INP. We regularly bump the library to its latest version. Sounds easy, right? Well, there’s a little bit more to this: We strip out a good part of unnecessary code to optimize for bundle size. We’re also more defensive in assuming available browser APIs, so we need to check every browser API call and add additional guards.
Most importantly, the library can introduce breaking changes which we need to take into account as well. In the last update from 4.2.5
to 5.0.2
, the library dropped reporting the FID web vital, since it was already deprecated and discouraged to be used for a long time (INP, its successor, is by now well established). We still shipped the update to 5.0.2
in version 9.29.0
of the SDK but kept the old FID code from 4.2.5
around. Only in version 10.0.0
did we finally remove FID to avoid breaking anyone relying on FID (for example in dashboards) during v9.
Standalone Web Vital Spans
For a while now, we’ve been working on significantly improving the quality of LCP and CLS web vitals. So far, the web vital measurements are collected when the pageload span ends. However, chances are that LCP or CLS values only stabilize after the pageload span already ended, in which case we wouldn’t pick up the final values. Therefore, we decided to detach CLS and LCP from the pageload span and send a standalone span for each of them instead. This allows us to wait for the final values instead of taking what’s there at pageload span end time:
You can enable standalone spans for now with the ease of setting two experimental options:
Sentry.init({
integrations: [
Sentry.browserTracingIntegration({
_experiments: {
enableStandaloneLcpSpans: true,
enableStandaloneClsSpans: true,
}
})
]
});
Both features spans are still opt-in because they require a more recent self-hosted Sentry version than what the SDK currently requires. We’ll enable them by default in the next major version of the SDK.
What’s next?
We’re not done yet! We still have a ton of ideas and we recently wrote up a roadmap with improvements we want to tackle in the future. To give you a quick TL;DR: We’re looking into significantly improving SSR traces, more links between frontend traces and ensuring sampling consistency. Here’s a quick teaser:

And here’s the obligatory meme:
In all seriousness though, if you’re interested, please give the roadmap a read, chat with us in Discord, and let us know what you think!