How We Started Optimizing Web Vitals: Our Own RUM and Bundle Cleanup
By Pavel Deev, Senior Frontend Developer at JoomPro

Introduction
At JoomPro, we are building a cross-border B2B marketplace, and Brazil is currently our primary market. Last autumn, we faced the challenge of optimizing Web Vitals. Our primary goal was to improve the user experience, though we also anticipated a positive impact on our organic search rankings.
A bit of context about our stack:
- Next.js
- React
- TypeScript
- Effector as our state manager
In parallel, we were migrating from the Pages Router to the App Router — the two routing systems in Next.js. With the Pages Router, pages are defined by files in the pages/ directory: a page is rendered on the server as a whole and hydrated on the client as a whole. The App Router (the app/ directory) is built around React Server Components and streaming: a page is composed of server and client components, and its markup is sent to the browser in chunks, as each part becomes ready. The migration affected the metrics in both directions. On the one hand, the App Router allowed us to show content to the user sooner, which helped LCP. On the other hand, progressive loading comes at a price: blocks appear on the page as they become ready and can shift the layout, which creates a risk for CLS. This will come up again later in the article.
Which Metrics We Looked At
To evaluate performance, we naturally relied on Web Vitals, the standard used by Google. Core Web Vitals comprise three main metrics:
- Largest Contentful Paint (LCP) — how quickly the user sees the main content.
- Interaction to Next Paint (INP) — how quickly the page responds to user actions.
- Cumulative Layout Shift (CLS) — how much the interface jumps around while loading.
First, the data is aggregated. CrUX shows the state of the whole origin, broken down by device type and by a limited number of individual pages at most, with no arbitrary segmentation. What we wanted was to fix things segment by segment — to look at the metrics by page, device, and browser. "LCP on the site is 3 seconds" is one thing; "LCP on the product page on mobile is 5 seconds" is quite another. The first tells you nothing about where to start, while the second is already a concrete task.
So we decided to build our own RUM — Real User Monitoring.
How We Collected RUM Data
To collect metrics in the browser, we used the web-vitals library, and to store and display them, we used Prometheus. At a high level, the setup looked like this:
- A user opens a page.
Web-vitalsmeasures LCP, INP, and CLS directly in their browser.- The client sends the resulting values to our Node.js BFF server, where they are stored in memory.
- Prometheus periodically scrapes the metrics from the instances.
- We use them to build charts and look at percentiles, usually the 75th.
However, due to how Prometheus works, we couldn’t store metric values in their raw form. For histograms, you need to define in advance a set of buckets into which the measurement results will be distributed. For example, if a user’s LCP is 2.7 seconds, it will fall into one of the predefined ranges. Percentiles can then be calculated from the distribution across those ranges.
While this may seem like a minor technical detail, the buckets you choose directly impact chart accuracy. If they are too wide, it becomes difficult to tell whether the metric has actually improved. This detail will come into play again, but in the following parts, when we get to fine-tuning the metrics.
We used CrUX as an external reference point and our own RUM as a tool for diagnosis and quick validation of changes.
What Our Starting Point Looked Like
In August-September 2025, our baseline metrics were as follows. Throughout this article, all values reflect the 75th percentile from CrUX across both desktop and mobile, as tracking the overall trend is our primary focus:
It was clear that our main problems were LCP and INP. LCP was in the “needs improvement” range, while INP was beyond the threshold for a good value. CLS, meanwhile, was already in the green, so we did not focus on it separately at the start.
In addition to Web Vitals themselves, we looked at how much JavaScript was loaded when a page was first opened. At the start, our total First Load JS — the code loaded on every page — was about 349 KB gzip. From here on, I will simply refer to it as the bundle. The first thing I decided to focus on was something that looked fairly straightforward and could have a significant impact: reducing the size of the client bundle.
Why We Started with the Bundle
The logic was straightforward: less JavaScript means faster download times. But downloading is only part of the story: JavaScript also needs to be decompressed, parsed, compiled, and executed. All of this occupies the main thread, especially on less powerful mobile devices.
Reducing the bundle does not in itself guarantee an improvement in LCP or INP. LCP, for example, may be affected much more by TTFB or the loading of the main image. But unnecessary JavaScript certainly does not help:
- It delays page hydration.
- It increases the time before the interface is ready.
- It slows down responses to user actions.
So cleaning up the bundle seemed like a good starting point: the result was easy to measure, and any unnecessary code we found should not have been loaded for every user anyway. We did not have a major CLS problem at the start: a significant share of our pages was still running on the Pages Router and was shown to the user already close to its final state, without a large number of blocks gradually appearing.
How We Looked for Unnecessary Code
When Next.js builds the application, it splits the code into several parts: some is shared across all pages, while some is loaded only on specific routes. To understand what exactly our JavaScript consisted of, I used Bundle Analyzer.
From there, the process was fairly straightforward: I looked at the largest modules and asked a few questions about each one:
- Is this code needed in the browser?
- Is it needed during the initial page load?
- Can it be moved to the server?
- Can it be loaded only when it is actually used?
- Has some heavy static asset accidentally ended up in the JavaScript?
This helped us find several large packages that either were not needed on the frontend at all or could be moved to the server side without any issues. Two illustrative examples are ua-parser-js and type-is. The former parsed the User-Agent directly on the client, even though we could detect the browser and platform on the server and send ready-made flags to the client. The latter was used in just one place in the transport layer — we inlined the content-type check in a couple of lines, and the entire dependency disappeared from the bundle. But that was not the end of it.
Lazy-Loaded Popups
Our bundle included popup code, even though users do not need it at all when they first open the page. A popup might never be opened, but its components, dependencies, and logic were still loaded along with the main page.
We moved those components into lazy chunks. Now the popup code is loaded only when the user is actually about to open it. This does not remove the JavaScript from the application entirely, but it does take it out of the critical path of the initial load. The user no longer pays for functionality they will most likely never use.
Heavy SVGs Inside JavaScript
There is a common belief that SVG can solve almost every image resizing problem: unlike raster graphics, it does not become blurry when scaled up. This is generally true, but the format alone does not solve every problem associated with loaded assets. If an SVG is complex and contains many elements, it can be many times larger than an equivalent raster image. And if it is imported as a React component, the entire image can become part of the JavaScript bundle. As a result, the browser does not simply download an image — it has to download additional JavaScript, parse it, and execute it.
For example, I found a banner where one such image added about 52 KB gzip to the bundle. It took a single change to fix: I replaced the SVG with a WebP served through a regular <img>. You cannot inline a raster image into JSX as a component, so the delivery method changed along with the format — the image became a separate asset and disappeared from the bundle entirely. The browser loads it in parallel, without spending time parsing or executing JavaScript for it, and it now weighs 7 KB.
One important nuance: it might be tempting to hide an image like this in a CSS background, but for a banner, that is a trap. The browser only discovers a background-image after CSS has been loaded and applied, and you cannot assign it a fetchpriority. If the banner turns out to be the LCP element, you trade bundle kilobytes for a later start to the image load itself. With <img> and priority/fetchpriority="high", the browser learns about it immediately from the HTML.
This was a good example of why Bundle Analyzer is not just about libraries: sometimes one of the heaviest “modules” turns out to be an image.
Sentry in the Main Bundle
One of the most interesting improvements involved Sentry. Naturally, nobody wanted to get rid of it entirely: we still needed to collect errors. But Sentry accounted for about 36 KB gzip in our bundle, loaded for every user regardless of whether they encountered an error or not (they probably would, but there is no need to load the SDK in advance just for that).
We decided to try lazy initialization. The main idea was not to load the entire Sentry client upfront, but to fetch it only when an error that needed to be reported actually occurred.

This implementation comes with a few nuances: you need to wait for the client to load before sending the event, preserve the context accumulated before the error, and avoid loading Sentry multiple times if several exceptions occur one after another. In simplified form, the core of the solution looks like this:
import type * as Sentry from '@sentry/nextjs';
type Breadcrumb = Parameters<typeof Sentry.addBreadcrumb>[0];
let sentry: typeof Sentry | undefined;
let sentryPromise: Promise<typeof Sentry> | undefined;
const bufferedBreadcrumbs: Breadcrumb[] = [];
// One promise for every call: no matter how many errors occur,
// the SDK will be loaded and initialized exactly once
function loadSentry() {
sentryPromise ??= import('@sentry/nextjs')
.then((loaded) => {
loaded.init({/* dsn, integrations, beforeSend filters */});
// Replay the context accumulated before the SDK was loaded
bufferedBreadcrumbs.forEach((b) => loaded.addBreadcrumb(b));
bufferedBreadcrumbs.length = 0;
sentry = loaded;
return loaded;
})
.catch((error) => {
// Do not cache a failure: the next error will try
// to load the SDK again
sentryPromise = undefined;
throw error;
});
return sentryPromise;
}
// Breadcrumbs are created for almost every user action, so by
// themselves they do not load the SDK. Until it is available,
// they accumulate in a buffer with the same 100-item limit as
// Sentry itself. Once the SDK is loaded, they are sent directly to it.
export function addBreadcrumb(breadcrumb: Breadcrumb) {
if (sentry) {
sentry.addBreadcrumb(breadcrumb);
return;
}
bufferedBreadcrumbs.push(breadcrumb);
if (bufferedBreadcrumbs.length > 100) {
bufferedBreadcrumbs.shift();
}
}
// An error, on the other hand, is a reason to load the SDK
// and send the event.
//
// .catch is essential here: if the SDK failed to load because
// of the network or an ad blocker, the rejected promise would
// produce an unhandledrejection. Our own global listener would
// catch it and call captureException again, creating an infinite loop.
export function captureException(error: unknown) {
void loadSentry()
.then((loaded) => loaded.captureException(error))
.catch(() => {});
}The whole trick rests on two things. A single cached promise guarantees that the SDK is loaded once, while any errors that occur during loading simply subscribe to it and are sent after initialization. The breadcrumb buffer lets us avoid loading the SDK for every user action without losing context: when the first error occurs, everything accumulated so far is replayed into the newly initialized client.
One gap remains: errors that occur before someone explicitly passes them to captureException. For those, we attached tiny error and unhandledrejection listeners to window. Before the SDK is loaded, they forward events to the same captureException; after initialization, they stop. The listener checks whether the SDK has already loaded, and as soon as Sentry’s global handlers are in place, forwarding is disabled — otherwise every error would be sent twice. In the end, we preserved full error monitoring while removing another large chunk of code from the bundle.
What We Achieved
As a result, total First Load JS dropped from about 349 KB to 105 KB gzip — we removed roughly 70% of the JavaScript that had previously been loaded whenever any page was first opened.
Our most significant wins included extracting a heavy SVG banner into a regular image (-52 KB) and lazy-loading Sentry (-36 KB). The rest came from many smaller changes: lazy-loaded popups, removing libraries such as ua-parser-js and type-is, moving code to the server, and deleting things that had no business being on the client in the first place.
Importantly, reducing the bundle did not translate into a proportional improvement across all Web Vitals. It would have been too easy if removing 70% of the JavaScript automatically improved LCP by the same 70%. But this work gave us several things at once:
- It reduced the amount of JavaScript on the critical path.
- It lowered the load on the browser.
- It removed code that should never have reached the client at all.
- It showed that the main LCP problem was not limited to the bundle.
What We Learned
The main takeaway from this stage is that measurability must always precede optimization. CrUX helped us understand the big picture and how Google saw the product. Our own RUM let us evaluate changes more quickly and gradually break the metrics down into their components. Bundle Analyzer proved useful for more than finding heavy libraries: it can reveal architectural problems — server code on the client, functionality outside the critical path, and even images accidentally embedded in JavaScript.
By the end of this stage, the metrics looked like this, using the same methodology: CrUX, p75, desktop + mobile.
Both problematic metrics moved in the right direction, but they still fell short of the “good” thresholds of 2.5 seconds and 200 ms.
An attentive reader will notice that CLS increased in the process. This is partly a consequence of moving to the App Router — the very price of progressive loading that I warned about in the introduction. Pages are now delivered in parts, and the user sees more skeletons: content arrives and replaces a placeholder, and if their dimensions do not match perfectly, the layout shifts. We consider this trade-off acceptable: the user starts seeing the page sooner, while CLS remains comfortably in the green even after the increase, with room before the 0.1 threshold. That said, we did not simply watch the metric: whenever CLS grew noticeably somewhere, we went looking for what was shifting the layout and fixed it — most often it came down to skeletons: either their dimensions did not match the actual content, or there were no skeletons at all and the content appeared in empty space, pushing the page apart.
One more important note about all the numbers above: our optimizations are not the only reason they moved. The product kept evolving the whole time — we were shipping new features and testing product hypotheses, and every such change adds code and markup to the page and affects the metrics too, often for the worse. We were not optimizing a frozen site but one that was changing in parallel, so the final values are always the sum of our improvements and the product changes.
To sum up this first stage: we reduced the bundle from 349 to about 105 KB gzip, while the metrics improved much more modestly. The data showed us where to dig next: we still had a high TTFB, and it was unclear what it consisted of — server work, the network, redirects, or the distance between our servers in Europe and our users in Brazil. So our next step was to break page loading down into individual stages, and that is where we found the most interesting things for scaling the JoomPro platform. I will cover that, as well as experiments specifically with LCP and INP — what worked, what did not, and what we had to roll back — in the following parts. Spoiler: not everything that promises to improve metrics actually does, and the negative results proved no less instructive than the successes.