Hunting a Frontend Memory Leak: Catching the Slow Crash in Long-Lived SPA Sessions
EngineeringOctober 27, 20269 min read

Hunting a Frontend Memory Leak: Catching the Slow Crash in Long-Lived SPA Sessions

Dashboards left open for hours bleed memory until the tab crashes—and the crash report tells you nothing useful. Here's how to track one down by lining heap growth up with what the user did.

Picture a ticket that says the dashboard freezes after about four hours, the tab becomes unresponsive, the only fix is a hard refresh, and it happens every day.

Not "sometimes." Every day. Four hours. That's not a bug report, that's a scheduled appointment with misery. Someone leaves a dashboard open all day (a perfectly reasonable thing to do with a dashboard), and around lunch the browser starts to choke.

You check error tracking. Nothing. Zero errors around the freeze times. That makes sense, because when a tab runs out of memory there's no graceful exception. JavaScript doesn't get to throw. The browser stops, or Chrome quietly discards the tab, and your error SDK dies before it can phone home.

That's the annoying thing about memory leaks. They don't announce themselves. They accumulate, slowly, and then everything dies at once.

Why the usual approaches to frontend memory leak detection stall

The textbook move is to reproduce locally: open the app, leave it, watch the Chrome DevTools memory panel. Heap grows, but at a gentle rate, and you can't stare at a graph for six hours.

Heap snapshot comparison is the next stop. Snapshot at T0, snapshot thirty minutes later, diff. On a real app the diff is mostly noise: thousands of strings, a few detached DOM trees, listeners you can't easily trace to code. You can see something is accumulating. You can't see what, and you can't see which user action caused it.

Adding your own heap measurements helps with the "that" and not the "what". You get a beautiful chart of memory climbing, then the data just stops when the tab dies. No final measurement, no breadcrumb.

If the user interacts with the app every twenty minutes and otherwise leaves it alone, the leak is probably tied to an interaction rather than a background timer. But a dashboard has filters, date pickers, chart drills and table sorts. Any of them could be the culprit.

The fix is correlation: heap growth next to user actions

Send heap measurements as timestamped events, then line them up against session replay by clock time. Scrub the replay to the moment growth accelerated and watch what the user did.

The correlation here is by timestamp: find the second when heap growth jumped, then go to that moment in the replay to see what the user did. Match by user and time window, and expect to nudge by a few seconds for perfect alignment.

With JustAnalytics the tracker exposes window.JustAnalytics.track(eventName, properties), so each measurement can go out as a custom event:

function reportHeap(props) {
  window.JustAnalytics.track('heap_measurement', props);
}

async function logHeapSize() {
  if (performance.measureUserAgentSpecificMemory) {
    try {
      const result = await performance.measureUserAgentSpecificMemory();
      reportHeap({ total_bytes: result.bytes, method: 'measureUserAgentSpecificMemory' });
      return;
    } catch (e) {
      // Needs cross-origin isolation; fall through to the legacy API
    }
  }
  if (performance.memory) {
    reportHeap({ total_bytes: performance.memory.usedJSHeapSize, method: 'performance.memory' });
  }
}

setInterval(logHeapSize, 60000);

Sixty seconds is a reasonable interval. Fast enough to see a step change, slow enough that measuring doesn't become the leak.

A note on the browser: JustAnalytics loads from its script tag, so there's no npm package to install for this. The npm packages it does publish are @justanalyticsapp/node, @justanalyticsapp/react-native and @justanalyticsapp/cli.

Reading the data

Once heap events flow in, the workflow is short:

  1. Find sessions where heap grew past a threshold you care about, say 100MB, within a window.
  2. Open the replay and jump to the timestamp where growth accelerated.
  3. Watch the thirty seconds around it.
  4. Look for the repeating action. If every time the user opens the same modal the heap steps up, you've found the suspect.

That last step is the one separate tools make painful. If errors live in one product, replay in another and performance metrics in a third, you're matching timestamps across three UIs over a multi-hour session. Good luck finding a two-minute modal interaction in four hours of footage. Even with everything in one platform you're still matching by time, but at least it's one place to look.

The usual suspect: a chart that never gets destroyed

Here's the shape of bug this method tends to expose. A modal renders a Chart.js canvas with a few thousand data points. The component creates a chart instance in an effect and never tears it down:

useEffect(() => {
  const chart = new Chart(canvasRef.current, {
    // ... config
  });

  // Missing: return () => chart.destroy();
}, [data]);

Each open of the modal leaves a zombie instance holding the canvas context, the data arrays and the hover listeners. None of it is collectable, because it's all still referenced. Open and close the modal a dozen times over an afternoon and the heap steps up every single time.

The fix is one line:

useEffect(() => {
  const chart = new Chart(canvasRef.current, { /* config */ });
  return () => chart.destroy();
}, [data]);

Obvious once you see it. Finding it in a 100,000-line codebase without the replay is where the days go.

Common memory leak patterns in SPAs

The patterns are predictable.

Chart library leaks. Chart.js, Recharts, D3 behind React wrappers: all need explicit cleanup. If you create charts dynamically and never call .destroy() or the equivalent, you're leaking.

Modals and popups that don't unmount cleanly. Portal-based modals can leave listeners attached to document. createPortal is fine, but scroll or resize listeners you attach in useEffect need cleanup.

WebSocket connections that outlive components. A chat widget subscribes, the component unmounts, the socket stays open, callbacks keep firing on stale closures. Close the connection in cleanup, or use a manager that tracks active subscriptions.

Accumulated state in module-level caches. A routing layer that appends every transition to a history array nobody trims is fine for a few minutes and terrible over an all-day session.

Event listeners on global objects. window.addEventListener('resize', ...) inside a component that mounts and unmounts often. Each mount adds one, nothing removes it, and eventually a single resize fires dozens of callbacks.

Most leaks are boring. The hard part is finding them, which is why the heap-to-replay link matters.

For session replay with GDPR-safe masking, you get the user actions without capturing sensitive data. And because you can line replay up with the memory timeline, you're debugging behaviour, not just code.

If your traffic is paid, a leak that crashes tabs also produces bounced sessions. Those look like low-quality traffic in your reports but are real users hitting a real problem. Correlating crashes with traffic source, including traffic you filter with ClickzProtect, helps you tell the two apart.

What this approach won't catch

Server-side leaks. This is all client-side. A leaking Node.js backend needs APM, and JustAnalytics has distributed tracing for backend correlation, but the heap tracking above is browser-only.

Native memory (WebGL, WASM). The JavaScript heap APIs don't capture GPU memory and may undercount WebAssembly allocations. Heavy 3D or Rust-compiled code needs specialised profiling.

Leaks that only show at scale. One replay won't reveal something that needs thousands of users to become visible. You need aggregate metrics.

Third-party scripts. If a marketing tag is leaking, you can see the heap grow and narrow down when it started, but you can't see into their code. The fix may be removing the script.

Also worth saying: performance.measureUserAgentSpecificMemory() is Chromium-only and needs cross-origin isolation headers, which is a genuine pain to roll out on an app that embeds third-party content. Expect to rely on the legacy API in plenty of setups, and treat its numbers as rough.

This is a debugging tool, not a prevention system. It finds leaks faster. It doesn't stop you writing them.

The bigger picture

Memory leaks in long-lived SPA sessions make you feel dumb. The fix is usually a missing cleanup function or a forgotten .destroy(). Locating it is the expensive part.

Heap snapshots and manual profiling work, but they're slow, and they assume you already suspect the leaking component, which is exactly what you don't know. Heap measurements plus session replay, matched by timestamp, turn the search into a query and a short watch.

For teams building complex B2B SaaS dashboards, this is table stakes. Your users leave tabs open. They will hit your leaks, and uptime checks won't tell you: the server is fine, the API is fine, the user's browser is dead. If you aren't correlating client-side health with the rest of your observability, an entire class of issues stays invisible.

Frequently Asked Questions

How do I detect a memory leak in a long-running SPA session?

Track heap size over time using performance.measureUserAgentSpecificMemory() or the Memory API. Log heap measurements at intervals—every 60 seconds works for most apps. When heap size grows consistently without plateauing, you have a leak. Correlate the timestamp when growth accelerated with user actions in session replay to find the trigger.

Why do crash reports not show the real cause of memory leaks?

When a tab runs out of memory, the browser kills it. There's no graceful error to catch—the JavaScript execution context is gone. The crash report, if you get one, shows the moment of death (usually an allocation failure), not the thousand small allocations that got you there. You need the history leading up to the crash, not just the final frame.

Can session replay capture the user actions that caused a memory leak?

Yes. Session replay records DOM interactions, navigation, and timestamps. When you see heap growth spike at a specific moment in your memory timeline, you can jump to that exact second in the replay and see what the user did—opened a modal that didn't unmount, triggered a subscription that never unsubscribed, or loaded a chart that kept accumulating data.

What are the most common causes of memory leaks in React and Vue apps?

Forgotten event listeners that aren't removed on unmount. setInterval or setTimeout callbacks holding references to stale closures. WebSocket or EventSource subscriptions that stay open after component destruction. Large datasets cached in module-level variables. Chart libraries that don't clean up their canvas contexts. Most leaks are mundane—just references that outlive their usefulness.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39/month billed annually).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts