A SaaS founder once messaged me in a panic: "Our Lighthouse score is 98. Our churn is climbing. Users say the app feels laggy. I don't understand the metrics say we're fast."
I opened the app. The dashboard loaded in under a second. Then I typed into the search box, and the entire page froze for half a second before the letter appeared. I clicked a tab. Another freeze. I scrolled a table of customer records. The frame rate collapsed.
The Lighthouse score was real. So was the churn. Both were telling the truth, they were just answering two completely different questions.
Users don't experience Lighthouse scores. They experience waiting.
That distinction is where most engineering teams lose the plot, and it's expensive. Not "slightly annoying" expensive, churn, refund requests, and "why does this feel cheap" Slack messages from your own sales team expensive.
Why Lighthouse Isn't the Full Story
Here's the opinion I'll defend to anyone: optimizing for Lighthouse is optimizing for the wrong ten seconds of your user's session.
Lighthouse audits one thing, a cold page load, in a lab, on a simulated device, on a throttled network. It never types into your form. It never opens your modal. It never filters your table. It measures the moment before the user starts actually using your product, then stops watching.
Everyone optimizes Lighthouse. Almost nobody optimizes the other 99% of the session, the part where the user is clicking, typing, scrolling, and forming an opinion about whether your product feels expensive or cheap.
That's the part your users actually live in. Nobody churns because the first paint took 1.8 seconds instead of 1.2. They churn because every time they type in a filter box, the app hesitates just long enough to feel broken.
React apps make this gap worse, not better, because React apps aren't static pages, they're long-running sessions with state that mutates constantly. A React app can post a flawless Lighthouse score and still degrade into something unusable three clicks after load, and there is no contradiction in that. They are two separate engineering problems, measured by two separate tools, and only one of them produces a number you can put in a sprint review.
React isn't slow. Poor architecture is. The framework gets blamed for what is almost always a state-management decision made in week two of the project, quietly compounding for the next two years.
None of this makes Lighthouse worthless, it's the right tool for a real job, and load time still affects conversion and SEO. The trade-off is where you spend a finite optimization budget: chasing the last few load-time points, or protecting the interaction quality of a session that might last twenty minutes. Most teams need both eventually. Almost none of them are spending that budget in the right proportion.
The 5 Metrics That Actually Matter
If Lighthouse's load metrics don't explain the "feel," here's what does, and what I actually look at when a founder tells me their app feels sluggish:
Input latency. The gap between a click or keystroke and a visible response. Past ~100ms, humans start registering it as "delay," not "computer." This has nothing to do with page load, it's what's blocking the main thread right now, mid-session.
Re-render count per interaction. Not "is this component fast", how many components re-render from a single state change. A single checkbox toggle re-rendering forty unrelated components will feel sluggish even if each individual render is fast in isolation.
Long tasks during interaction, not during load. Lighthouse penalizes long tasks in the first few seconds. It has nothing to say about the 400ms main-thread block that happens when a user opens a modal in minute six of their session.
Layout thrash. Reading
offsetHeightor callinggetBoundingClientRectinside a render path or a loop forces the browser to recalculate layout synchronously. This is invisible in code review and brutal in production.Frame rate during scroll and drag. Not a vanity FPS counter — whether the UI drops frames while someone is actively interacting. This is the single most "felt" metric in the entire list, and it's the one product teams measure least.
These five don't carry equal weight for every product. An internal admin tool with five power users can tolerate more input latency than a customer-facing dashboard a prospect is watching during a sales demo. Knowing which of the five matters most for your product is a business decision as much as a technical one, and it's worth making explicitly instead of by accident.
Here's the insight most engineers miss: none of these five metrics live on a page-load timeline. They live on an interaction timeline. You're debugging the wrong timeline if you're only ever looking at Lighthouse.
A Representative Case: The Dashboard That Looked Fine and Wasn't
Picture a B2B analytics dashboard: roughly 1,200 rows in a customer table, live-updating, with a global filter bar at the top. Lighthouse score: 96. Support tickets tagged "app is slow" or "app feels laggy": climbing about 15% month over month, concentrated almost entirely on this one screen.
Profiling the filter interaction showed:
Every keystroke in the filter box triggered a re-render of roughly 34 components, not just the table
Input latency of around 420ms between keystroke and visible update
Frame rate during table scroll dropping to ~18fps from a resting 60fps
The root cause: a single AppContext held user session data, theme preference, notification counts, and the active filter string, all in one object, all delivered through one provider. Any change to any one field re-rendered every consumer of that context, regardless of whether that consumer cared about the field that changed.
Three fixes were on the table, and each one had a real cost attached to it, this is the part most write-ups skip:
Option 1 - Add React.memo everywhere. Fastest to ship, touches no architecture. But it treats the symptom, not the cause: the context still notifies every consumer, memo just decides slightly faster whether to skip the re-render. It also becomes a permanent tax, every future component built on this context needs someone to remember to memoize it too, or the problem quietly returns.
Option 2 - Migrate to Redux or Zustand. Fixes the root cause properly. Selector-based subscriptions mean components only re-render when the slice they actually read changes. But it's a real migration: every existing consumer needs to be touched and re-tested, and the team needs to already know, or now learn, a new state model. For a small team under a launch deadline, that's a multi-week cost, not an afternoon.
Option 3 - Split the one context into four. Session, theme, notifications, and filters each get their own provider. Consumers subscribe only to the slice they need. This captures most of the re-render benefit of Option 2 at a fraction of the migration cost, with no new library or mental model to onboard the team onto.
We went with Option 3, not because it's the objectively "best" architecture (a well-designed Redux or Zustand store would likely outperform it at larger scale), but because it fixed the actual re-render fan-out in about two days instead of two sprints, on a team that didn't have two sprints to spare. The right fix is the one that matches your actual constraints, not the one that wins in a blog post.
Result after the fix:
Metric | Before | After |
|---|---|---|
Components re-rendered per filter keystroke | ~34 | ~3 |
Input latency on filter | ~420ms | ~40ms |
Scroll frame rate on table | ~18fps | ~57fps |
"App is slow" tickets (monthly trend) | Climbing ~15% MoM | Flat, then declining |
The business impact mattered more than the technical one. This wasn't just "the app got faster." A support team that was fielding the same complaint weekly stopped fielding it. A sales team that had started proactively apologizing for "some lag on the dashboard" during demos, stopped needing to.
The Mistake Most Teams Make
Here's where I'll disagree with a lot of standard advice: most teams reach for useMemo and React.memo before they've profiled anything, and it usually makes the codebase harder to reason about without fixing the actual problem.
The common advice is "memoize expensive computations and pure components." That's not wrong in principle. In practice, most engineers apply it reflexively, wrapping components in memo and calculations in useMemo as a defensive habit, the same way people over-comment code "just in case." That habit is expensive in ways that don't show up immediately:
Memoization has its own cost. Comparing props or dependencies isn't free, and on components that re-render rarely or cheaply anyway, you've added complexity for negative or negligible return.
It creates a false sense of "we've optimized this." The next engineer who touches the file assumes the performance work is already done and stops looking for the real root cause, the shared context, the unstable prop reference, the effect with a missing dependency.
It's genuinely harder to review. A
useMemowith a dependency array is a claim about why a value doesn't need recalculating. Without a profiler trace backing that claim, it's a guess wearing the costume of rigor.
The trade-off, stated plainly: profiling first costs you twenty extra minutes before you write the fix. Memoizing everything up front costs nothing today and a confusing, hard-to-untangle codebase eighteen months from now, once nobody remembers which memoization calls were load-bearing and which were cargo-culted.
The same instinct shows up with list virtualization. Teams virtualize a 40-row list because "virtualization is best practice," while a 1,200-row table three screens away, the one actually generating support tickets, never gets profiled, because nobody thought to look.
Optimize what you've measured. Not what you've read about.
How I Debug React Performance
There's no single universal order here, it depends on what you already suspect. But when I don't have a strong hypothesis yet, this is the sequence that gets me to the root cause fastest:
Name the exact interaction, not the vibe. "The app feels slow" isn't a bug report. "Typing in the customer search box lags by ~400ms" is. I make the person reproduce it live before I open a single tool.
React DevTools Profiler, before anything else. Record the interaction. Read the flamegraph. Which components rendered, how many times, how long did each take. This single free tool answers most "why does this feel slow" questions in React apps.
Chrome Performance panel, for main-thread truth. Record the same interaction and look for long tasks, layout thrashing, and where the time is actually going, scripting, rendering, or painting.
Ask "why did this render" before reaching for
useMemo. Adding memoization to a component that wasn't the bottleneck doesn't fix anything, it adds a second thing to debug later. Confirm the cause before treating the symptom.Check where fast-changing state actually lives. Input values, scroll position, hover state, anything that changes on every keystroke or every pixel of scroll, should live as close to where it's rendered as possible. The moment fast-changing state lives in a provider that unrelated components also depend on, you've built a re-render engine, not a state container.
Re-measure the exact same interaction after the fix. If the render count or duration didn't meaningfully drop, the fix addressed a symptom, not the cause. Back to step 3.
The Profiler and the Performance panel aren't interchangeable, and knowing which one to reach for first saves real time: the Profiler answers "which components, and how many times." The Performance panel answers "where did the milliseconds actually go, script, layout, or paint." Reach for the Profiler when you suspect a re-render problem. Reach for Performance when the Profiler looks clean but the interaction still feels slow — that's usually a sign the cost is sitting in the DOM or in a non-React script, not in React's render cycle.
The fastest component is the one you never render. Every optimization technique in React — memoization, virtualization, code splitting, lazy loading, is really just an elaborate way of avoiding unnecessary work. The best fix is rarely "make the render faster." It's "don't render it at all."
What I Would Do If I Inherited This Codebase Today
If I walked into a codebase with the dashboard problem above, unfamiliar code, a live product, real users, and a backlog already full, here's the order I'd actually work in, and why.
Day 1: Stop diagnosing blind. Get one real number. Before touching a line of code, I'd profile the exact interaction users are complaining about and capture one hard number, re-render count, input latency, whatever's easiest to grab. Not because the number needs to be precise, but because "the app feels slow" isn't actionable and "34 components re-render on every keystroke" is. This costs an hour and prevents days of fixing the wrong thing.
Week 1: Ship the smallest fix that moves the number, not the "correct" fix. This is the judgment call I see even senior engineers get wrong: they spot the root cause, a poorly scoped context, in this case, and want to fix the architecture immediately. I'd resist that in week one. The priority is getting the support tickets to stop climbing. Splitting one context into four, or memoizing the two or three components actually causing the fan-out, is a defensible week-one fix even though it isn't the "final" architecture.
Month 1: Decide if the pattern is a one-off or a symptom. If the context problem is isolated to this one dashboard, I'd leave the incremental fix in place, a full state-management migration for a contained problem isn't worth the engineering time or the regression risk. If the same pattern shows up in three other screens, that's a signal the pattern itself is the real root cause, and it's worth the bigger investment: a proper state library, or at minimum a documented convention for how new context gets scoped.
Ongoing: Put a floor under regressions, not a ceiling on effort. I wouldn't chase every remaining microsecond. I'd add one guardrail, a simple review rule like "no context provider holds more than one concern," or a lightweight render-count check in CI on the interactions that actually mattered, so the same class of bug can't quietly come back in six months when someone adds "just one more field" to the context.
The prioritization principle underneath all of this: fix the bleeding before you fix the architecture. A codebase with a known, contained performance issue and happy users is in a better position than a codebase mid-refactor with an outage. Business impact sets the order of operations, not engineering elegance.
Performance Checklist
Save this. Run it before every "why is this slow" ticket turns into a two-day investigation.
Check | What it catches |
|---|---|
Profiled the specific interaction in React DevTools, not just page load | Confirms you're debugging the interaction timeline, not the load timeline |
Context providers reviewed for unrelated re-render fan-out | The "one context, everything re-renders" trap |
Props passed to memoized components checked for new object/array references | Silent memoization defeats |
Expensive derived data (filter/sort/map) wrapped in | Cost that only appears in production, never in a 10-row dev fixture |
| Effects firing far more often than intended |
Lists over ~100 rows virtualized | The "fine on load, chokes on scroll" pattern |
Layout-reading calls ( | Invisible layout thrashing |
Third-party scripts audited for global scroll/resize listeners | Tax paid on every interaction, on every page, forever |
Fixes re-measured on the same interaction, not assumed to have worked | The step almost everyone skips |
Lighthouse treated as a load-time tool only, never as a proxy for felt performance | The root misunderstanding this entire article is about |
Lighthouse tells you how fast your app loads. Users tell you how fast your product feels. If those two disagree, always trust the user, the user is the one deciding whether to renew.
If you're facing similar challenges building or scaling your SaaS product, you can Book a Product Strategy Call.
