Core Web Vitals are useful signals, but they are easy to turn into the wrong conversation.
I have seen this pattern a few times in large frontend systems. A dashboard turns red. The team opens Lighthouse. Someone asks why the app load is slow. Someone else finds a bundle that grew last sprint. A third person points at a third-party script. Two days later, we have shaved a few kilobytes, moved a tag, and convinced ourselves the performance work was done.
Sometimes that is the right work.
Often, for a single-page application, it is only the first page of the story.
Users do not experience an SPA as one page load. They experience a long session made of route transitions, async rendering, hydration, blocked interactions, lazy panels, SDK callbacks, product banners, and state updates that happen long after the initial document loaded.
Core Web Vitals do not lie about that experience. But they do answer specific questions.
The mistake is treating those answers as if they describe the whole app.
The second mistake is worse: replacing uncomfortable browser metrics with softer internal ones that are easier to win.
The most tempting version of that mistake is a metric called something like render time.
The name sounds serious. It sounds closer to an SPA than LCP. It sounds like the team is measuring what the user actually sees after route navigation, data fetching, and component rendering.
But “render” is not one moment in the browser.
The browser parses HTML, builds the DOM, computes styles, calculates layout, paints pixels, and composites layers into a frame. In frontend code, people also use “render” to mean a React render function ran, a Vue component mounted, a route component loaded, an API response arrived, a skeleton appeared, or the real content became visible.
Those are different events.
Then you ask where the stopwatch starts and stops.
Does render time start when the user taps? When the router begins navigation? Before the API call? After the API call? When the Vue component mounts?
And where does it stop?
When the first card appears? When the pixels are painted? When the loading skeleton disappears? When the slowest above-the-fold module finishes? When the user can actually interact without the main thread fighting back? When the team emits a custom “screen ready” event?
That is not a small detail. That is the metric.
If every product team can choose its own start and stop markers, render time becomes negotiation, not measurement. A number can improve because the app got faster. It can also improve because the boundary moved closer to the easy part of the work.
That is how a team can begin with 12s render time, set a target of 9s, hit the target, and still leave the user waiting in the same places that mattered before.
The chart goes green. The product does not feel faster.
A browser metric has an uncomfortable property: the browser decides when it happened. That makes Core Web Vitals annoying. It also makes them harder to negotiate away.
This is where incentives start to bend the system.
If one team is judged on a self-defined render time, and another team is judged against an externally defined browser threshold like 2.5s LCP, the organization has created two performance worlds. One metric can be shaped by instrumentation choices. The other is measured from the user’s browser.
The point is not that custom metrics are forbidden. A well-defined custom route metric can be valuable, especially in an SPA.
Measure feature-specific readiness. Measure checkout flow duration. Measure how long a business-critical widget takes to become useful. Measure whatever helps the team understand a specific product path.
But do not replace Core Web Vitals with it.
And if the custom metric matters, its boundaries have to be owned as seriously as the number.
If the metric answers “how long did our chosen code path take between two convenient marks?”, it is not a user-experience metric yet. It is an engineering stopwatch with nice branding.
Core Web Vitals are imperfect for SPAs. Fine.
Internal metrics can be worse.
Once you see the problem as a movable stopwatch, the Core Web Vitals conversation gets clearer.
LCP, CLS, and INP are not perfect descriptions of an SPA. They were not designed to understand every route transition, async render, client-side state change, or long-lived session by themselves.
That does not make them disposable.
It means the job is to add enough route, interaction, and session context that the browser signal becomes actionable. Not to replace it with a friendlier number whose boundary the team can move.
In a traditional document navigation, the browser requests a page, receives HTML, discovers resources, paints content, and the vitals describe that journey.
SPAs bend that model.
The initial page load can be a shell. The real screen appears after client-side routing, API calls, permission checks, feature flags, remote config, and component-level data fetching. The slowest moment can be the third route transition, not the first load.
So when a team asks, “how do we improve LCP for this SPA?”, the question is already suspicious.
Which LCP?
The landing route on a cold load? The authenticated dashboard after the app shell hydrates? The product page reached after a client-side route transition? The hero image that appears only after pricing, eligibility, or personalization returns?
The browser’s LCP metric is still useful. It tells you when the largest visible content became available for a page load. That matters. A blank shell with a spinner is not a good first impression just because the app is technically interactive.
But the answer is not to demote LCP into a custom render time score. The answer is to connect the browser signal to the product path: route name, entry source, device class, release, data dependency, and the actual element that became the largest content.
We had the perfect version of this problem: a page whose LCP element was a banner image discovered from an API response.
The custom render time stopped after that specific API call resolved. On the chart, the route was done. In the browser, the image request had only just become discoverable. Download, decode, paint, and the actual LCP moment were still ahead.
The stopwatch stopped at data readiness. The user was waiting for pixels.
The browser metric did not lie.
The question did.
CLS exposes the same mistake from another angle.
A route transition starts with a stable skeleton. Then a banner arrives from remote config. Then a user-specific balance card changes height because the API response includes an extra warning. Then a bottom sheet mounts and changes scroll position. Then a font finishes loading on a label inside a sticky header.
Each individual change has a story.
The backend sent a field. The design needed a campaign slot. The component handles five product states. The experiment variant needed a larger badge.
None of those stories make the layout shift feel better.
The useful move is not to say “avoid CLS” as if that helps anyone. It is to look at the UI contract. Which regions are allowed to change after first paint? Which async modules reserve space? Which personalization states can appear above existing content?
In Vue, React, or any modern frontend, this is rarely one bad line of code. It is usually a missing platform habit: stable image dimensions, predictable route slots, reserved async banners, and loading states that match the size of resolved content.
That is platform judgment.
The CLS number is a signal. A custom “screen ready” mark does not cancel it.
The important question is whether the app has a contract for visual stability after data arrives.
For teams building SPAs, INP exposes the model of performance even more clearly.
Loading still matters. A slow load can ruin everything before the user gets a chance to click. But in a long-lived SPA session, interaction latency is where the product starts to feel heavy.
This is where a custom render time metric can be most misleading. The route can be “done” and the user can still be fighting the main thread.
A user taps a tab and waits while the app parses a large response. They type into a search input while a table recomputes on every keystroke. They open a modal and the main thread is busy hydrating a below-the-fold widget. They click submit and an analytics SDK, risk check, state update, validation pass, and route change all compete in the same turn.
The page loaded. The app is alive. The user still experiences it as slow.
That is why INP is uncomfortable in a useful way. It cares about the responsiveness of interactions, not the story we tell ourselves about when loading “ended”.
In production, bad INP is often not one dramatic bug. It is accumulated main-thread disrespect.
Too much work inside input handlers. Too many synchronous transforms after API responses. Too many components rerendering because state boundaries are loose. Too many third-party callbacks scheduled at exactly the moment the user is trying to do something. Too much hydration work treated as background just because it happens after the first paint.
The fix is not one trick. Sometimes it is yielding between chunks of work. Sometimes it is moving parsing off the main thread. Sometimes it is memoizing the expensive selector that runs every time the user types. Sometimes it is refusing to load a third-party SDK on routes where it has no job.
Reducing JavaScript is a start.
The better question is which JavaScript is blocking which user intent, at what moment, for which route, on which devices, under which release.
Same metric. Different level of judgment.
The same rule applies to custom metrics. If you measure a route transition from “after the API returns” to “component mounted”, you have measured part of the render pipeline. You have not measured the user’s wait.
That distinction matters.
Third-party scripts are a convenient villain, so they deserve some restraint.
Analytics, fraud detection, experimentation, support widgets, session replay, payment SDKs, and tag managers can all compete with product code for network, CPU, memory, and attention.
In fintech-scale frontend systems, you also do not get to pretend those scripts are decorative. Some of them are part of risk, compliance, payment success, or incident visibility. “Remove the script” is not always a serious recommendation.
The better question is ownership: which routes need it, when does it load, what does it cost on mid-range Android, who can see that cost in traces, and who removes it when the product no longer needs it?
Third-party code needs the same kind of budget as first-party code. The browser does not care that the long task came from a vendor.
Neither does the user.
This is why I trust production measurement more than performance theater.
Lab tools are useful. Lighthouse, local profiling, and synthetic checks all have a place.
But they do not know your route mix. They do not know that most high-value users start from a logged-in dashboard on Android. They do not know that the route with the worst CLS is reached through a campaign flow, not the home page. They do not know that an SDK callback fires only after a real account state is loaded. They do not know which release introduced the regression.
Production observability gives performance work its shape.
In practice, that means collecting Web Vitals with enough context to act: route name, navigation type, device class, release version, attribution data, and traces around API calls, long tasks, render boundaries, and third-party work.
Sentry and Web Vitals observability have been useful to me because they connect symptoms to real sessions. A metric alone says “users are waiting.” A trace can show whether they are waiting on an image, a validation request, hydration, a route guard, a script, a render loop, or a state update that should not be on the critical path.
That does not mean every article needs a dashboard screenshot.
It means the engineering habit should be evidence-first. Start from real user pain. Segment until the problem has a shape. Then choose the smallest change that attacks the thing in front of the user.
Custom metrics can sit beside that. They can explain a flow in more product-specific language than LCP, CLS, or INP ever will. They just should not become the place where uncomfortable browser signals go to become easier targets.
If the worst LCP route is not your home page, do not spend the week polishing the home page.
If the worst INP interaction is a filter panel, do not celebrate because the initial bundle got smaller.
If the worst CLS happens after personalization, do not stop at reserving image dimensions.
The metric is telling you where to look. You still need to know how to look.
The deeper issue is that many frontend teams treat Core Web Vitals as a scoring system instead of a feedback loop.
Score systems invite surface compliance. Make the number green. Pass the gate. Ship the release.
Feedback loops change architecture.
If route transitions keep feeling slow, the app shell is not enough and the route data model needs work. If hydration blocks interaction, the page is rendering too much client-only UI before the user can act. If CLS keeps coming from async content, the design system needs stricter layout primitives. If INP keeps regressing after harmless features, the shared state model and third-party loading strategy are too expensive for the devices your users actually own.
This is the part of frontend work I care about most: connecting browser-level signals to product and platform decisions.
The framework matters, but it is not the diagnosis. A Vue app and a React app can both be slow for the same reason: too much work sits in front of the user-visible moment. The real work is deciding what belongs on the critical path, what can wait, what can be reserved, what can be moved off the main thread, and what should not exist on that route at all.
That judgment shows up in the questions: where users enter, which routes matter, whether the metric is page-load or session pain, what work blocks the critical content or interaction, and who controls the measurement boundary.
Core Web Vitals do not replace frontend judgment.
They reveal where it has to show up.