React Memory Leaks Won't Crash Your App Until They Do: 18 Patterns in React and Next.js
You'll learn eighteen side-effect and reference patterns that leak memory in React and Next.js, what the failing code looks like, and the cleanup that actually stops it.
TL;DR: React won't throw when you leak memory. Timers, listeners, open connections, detached DOM nodes, and server-side module caches all hold references that outlive the component or request that created them. Every setup in useEffect needs a symmetric cleanup. Every async call needs cancellation or a stale guard. Closures and caches need bounds. On the Next.js server, module-level state persists across requests. Fix the lifecycle, not the symptom.
Your dashboard felt fine at 9 AM. By 2 PM the tab was sluggish. Chrome Task Manager showed 800 MB for a page that should sit around 80. You opened Performance Monitor, watched JS heap size climb in a staircase pattern every time someone navigated away and back, and never come back down.
No error in the console. No red overlay. React 18 and 19 stopped warning about updates on unmounted components because the real failure mode isn't a thrown exception. It's a reference that never gets released.
I've burned hours on this. The fixes are almost always boring: return a cleanup function, abort the fetch, close the socket, cap the cache. The hard part is knowing which patterns leak and which ones only look like they might.
This post walks through eighteen of them. Each one shows the failing code first, then the version that actually releases memory.
The first ten are lifecycle leaks: things you start in an effect and forget to stop. The next eight are reference leaks: things you hold onto after you should have let go.
What "Memory Leak" Means in React
JavaScript garbage-collects objects that nothing references anymore. A leak happens when your code keeps a reference to something that should have died: a DOM node, a closure over old state, an open WebSocket, a module-level Map on the server.
React components unmount constantly. Route changes in Next.js tear down entire subtrees. If your side effect registered something outside React's tree and didn't unregister it, that thing keeps living. The component is gone. The interval is still firing.
Two environments, two leak profiles:
| Environment | What persists | Typical leak source |
|---|---|---|
| Browser (Client Components) | Until tab close | Timers, listeners, open connections, detached DOM |
| Node.js (Server Components, Route Handlers) | Until process restart | Module-level caches, CSS-in-JS class caches, DB pools |
Three leak families show up repeatedly:
| Family | What goes wrong | Examples in this post |
|---|---|---|
| Lifecycle | Setup runs, cleanup never does | Timers, listeners, fetch, WebSocket |
| Reference | JS keeps pointing at dead things | Detached DOM, blob URLs, closure over large data |
| Architecture | State lives outside component scope | Server module cache, client module Map, CSS-in-JS |
The mental model: setup creates a process outside React. Cleanup must stop that process. For references, if nothing should need it anymore, break the chain. React's own docs call this "mirroring" setup with cleanup. Strict Mode runs setup → cleanup → setup in development specifically to catch asymmetry.
Part 1: Lifecycle Leaks
These are the ones that show up in every code review. Something starts in useEffect. Nothing stops it.
Example 1: setInterval Without clearInterval
The most common leak in code review. A polling notification badge starts an interval and never stops it.
// ❌ Leaks: interval survives unmount
function NotificationBadge() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(async () => {
const res = await fetch("/api/notifications/unread");
const { unread } = await res.json();
setCount(unread);
}, 5000);
}, []);
return <span>{count}</span>;
}Navigate away. The interval keeps running. It holds a closure over setCount. The fetch keeps firing against a component that no longer exists. In React 19 you won't get a warning. You'll get a slow tab.
// ✅ Fixed: clear on unmount
function NotificationBadge() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(async () => {
const res = await fetch("/api/notifications/unread");
const { unread } = await res.json();
setCount(unread);
}, 5000);
return () => clearInterval(id);
}, []);
return <span>{count}</span>;
}Notice the cleanup returns the same id that setInterval created. Store the handle. Clear it. One line, but it's the difference between a dashboard that runs all day and one that needs a refresh by lunch.
Example 2: Window Event Listeners That Never Come Off
Scroll tracking, resize handlers, keyboard shortcuts. Same pattern, different API.
// ❌ Leaks: listener accumulates on every mount
function ScrollProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
function onScroll() {
const scrolled =
window.scrollY /
(document.documentElement.scrollHeight - window.innerHeight);
setProgress(scrolled);
}
window.addEventListener("scroll", onScroll);
}, []);
return <div style={{ width: `${progress * 100}%` }} />;
}Every time this component mounts (route change, conditional render), another listener attaches. They stack. Scroll gets jankier. Memory climbs.
// ✅ Fixed: symmetric add/remove
function ScrollProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
function onScroll() {
const scrolled =
window.scrollY /
(document.documentElement.scrollHeight - window.innerHeight);
setProgress(scrolled);
}
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return <div style={{ width: `${progress * 100}%` }} />;
}Modern shortcut: pass { signal } from an AbortController to addEventListener. One controller.abort() in cleanup removes every listener registered with that signal. Useful when you attach three listeners in one effect.
Example 3: Fetch Without AbortController
This isn't always a classic "leak" in the GC sense. The response object and closure can linger until the request finishes. Worse: a slow response resolves after the user navigated away and writes stale state into the new page.
// ❌ Race condition + lingering reference
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then(setUser);
}, [userId]);
if (!user) return <Skeleton />;
return <h1>{user.name}</h1>;
}User switches from Alice to Bob. Alice's request resolves second. Bob's profile flickers to Alice. The closure holding Alice's setUser stays alive until that promise chain completes.
// ✅ Fixed: abort on unmount or userId change
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, [userId]);
if (!user) return <Skeleton />;
return <h1>{user.name}</h1>;
}When userId changes, React runs cleanup first (aborting the old request), then starts the new fetch. This is why libraries like TanStack Query exist: they bake this in so you stop hand-rolling it in every component.
Example 4: WebSocket Connections That Never Close
I once debugged a chat widget where memory climbed 4 MB per minute. The WebSocket useEffect had [messages] in its dependency array. Every incoming message re-ran the effect, opened a new socket, and left the old one open.
// ❌ Leaks: new socket on every message
function ChatPanel({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/rooms/${roomId}`);
ws.onmessage = (event) => {
setMessages((prev) => [...prev, JSON.parse(event.data)]);
};
return () => ws.close();
}, [roomId, messages]); // messages here is the bug
return <MessageList messages={messages} />;
}The cleanup function is there. The dependency array defeats it.
// ✅ Fixed: reconnect only when roomId changes
function ChatPanel({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/rooms/${roomId}`);
ws.onmessage = (event) => {
setMessages((prev) => [...prev, JSON.parse(event.data)]);
};
return () => ws.close();
}, [roomId]);
return <MessageList messages={messages} />;
}Rule: dependencies should list what the setup reads, not what the setup writes. setMessages with a functional updater doesn't need messages in the array.
Example 5: ResizeObserver Without disconnect
Observers are easy to forget because they're not timers or listeners in the traditional sense. They hold a native reference to the observed element.
// ❌ Leaks: observer never disconnected
function AutoHeightPanel({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(([entry]) => {
setHeight(entry.contentRect.height);
});
observer.observe(ref.current);
}, []);
return (
<div style={{ height }}>
<div ref={ref}>{children}</div>
</div>
);
}React 19 gives you a cleaner pattern. Ref callbacks can return a cleanup function, scoped to the DOM node's lifetime:
// ✅ Fixed: React 19 ref callback cleanup
function AutoHeightPanel({ children }: { children: React.ReactNode }) {
const [height, setHeight] = useState(0);
const measuredRef = useCallback((node: HTMLDivElement | null) => {
if (!node) return;
const observer = new ResizeObserver(([entry]) => {
setHeight(entry.contentRect.height);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div style={{ height }}>
<div ref={measuredRef}>{children}</div>
</div>
);
}If you're on React 18, the useRef + useEffect combo with return () => observer.disconnect() works fine. React 19 just removes the "wait for ref to populate" dance.
Example 6: Third-Party Instances (Charts, Maps) Not Destroyed
Libraries like Chart.js, Leaflet, or Monaco create instances tied to DOM nodes. React unmounts the div. The library instance doesn't know.
// ❌ Leaks: chart instance orphaned
function SalesChart({ data }: { data: number[] }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current) return;
const chart = new Chart(canvasRef.current, {
type: "line",
data: { labels: data.map((_, i) => i), datasets: [{ data }] },
});
}, [data]);
return <canvas ref={canvasRef} />;
}Each data update creates a new chart on the same canvas without destroying the old one.
// ✅ Fixed: destroy before recreate
function SalesChart({ data }: { data: number[] }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current) return;
const chart = new Chart(canvasRef.current, {
type: "line",
data: { labels: data.map((_, i) => i), datasets: [{ data }] },
});
return () => chart.destroy();
}, [data]);
return <canvas ref={canvasRef} />;
}Check the library docs. Almost every imperative DOM library exposes a destroy(), dispose(), or remove() method. That's your cleanup.
Example 7: Module-Level Cache in a Next.js Route Handler
Client-side leaks show up in Chrome. Server-side leaks kill your Node process at 3 AM.
Next.js Route Handlers and Server Actions run in a long-lived Node process. Module-level variables survive across requests.
// ❌ Leaks: app/api/search/route.ts, cache grows forever
const cache = new Map<string, SearchResult>();
export async function GET(request: Request) {
const query = new URL(request.url).searchParams.get("q") ?? "";
if (!cache.has(query)) {
const results = await expensiveSearch(query);
cache.set(query, results);
}
return Response.json(cache.get(query));
}Every unique query string adds an entry. Nothing evicts. After a week of traffic, the process hits OOM. Restart fixes it temporarily.
// ✅ Fixed: bounded LRU cache with TTL
import { LRUCache } from "lru-cache";
const cache = new LRUCache<string, SearchResult>({
max: 500,
ttl: 1000 * 60 * 5, // 5 minutes
});
export async function GET(request: Request) {
const query = new URL(request.url).searchParams.get("q") ?? "";
let results = cache.get(query);
if (!results) {
results = await expensiveSearch(query);
cache.set(query, results);
}
return Response.json(results);
}If you don't want a dependency, a plain Map with manual eviction works for small caches. The point is bounds: max entries, TTL, or both. Unbounded module state on the server is a time bomb.
Example 8: Growing Arrays Without a Cap
State that only appends and never trims looks innocent. It's one of the sneakier client-side leaks because each individual update is small.
// ❌ Leaks: message buffer grows without limit
function LiveLogFeed() {
const [entries, setEntries] = useState<LogEntry[]>([]);
useEffect(() => {
const ws = new WebSocket("wss://logs.example.com/stream");
ws.onmessage = (event) => {
const entry = JSON.parse(event.data) as LogEntry;
setEntries((prev) => [...prev, entry]);
};
return () => ws.close();
}, []);
return (
<ul>
{entries.map((e) => (
<li key={e.id}>{e.message}</li>
))}
</ul>
);
}The WebSocket cleanup is correct. The array isn't. After an hour, you're holding 50,000 log entries in React state. Re-renders get slower. Memory climbs.
// ✅ Fixed: cap the buffer
const MAX_ENTRIES = 200;
function LiveLogFeed() {
const [entries, setEntries] = useState<LogEntry[]>([]);
useEffect(() => {
const ws = new WebSocket("wss://logs.example.com/stream");
ws.onmessage = (event) => {
const entry = JSON.parse(event.data) as LogEntry;
setEntries((prev) => [...prev, entry].slice(-MAX_ENTRIES));
};
return () => ws.close();
}, []);
return (
<ul>
{entries.map((e) => (
<li key={e.id}>{e.message}</li>
))}
</ul>
);
}For a log viewer, you might virtualize the list instead of capping. But something must bound the data. Infinite append is infinite memory.
Example 9: Custom Event Emitter Subscriptions
Pub/sub outside React (mitt, Node's EventEmitter, a global store's subscribe) follows the same rule as DOM listeners.
// ❌ Leaks: subscribe without unsubscribe
import { appEvents } from "@/lib/events";
function ToastListener() {
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
appEvents.on("toast", (message: string) => {
setToast(message);
});
}, []);
if (!toast) return null;
return <div className="toast">{toast}</div>;
}Navigate away. The handler still fires. It still closes over setToast from the dead component.
// ✅ Fixed: unsubscribe in cleanup
import { appEvents } from "@/lib/events";
function ToastListener() {
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
function handleToast(message: string) {
setToast(message);
}
appEvents.on("toast", handleToast);
return () => appEvents.off("toast", handleToast);
}, []);
if (!toast) return null;
return <div className="toast">{toast}</div>;
}Same function reference in both on and off. If you pass an inline arrow to on, you can't remove it with off unless the emitter supports returning an unsubscribe function. Prefer that API when it exists.
Example 10: URL.createObjectURL Without revokeObjectURL
File previews and blob downloads create object URLs that pin the underlying blob in memory until revoked.
// ❌ Leaks: blob URLs never revoked
function ImagePreview({ file }: { file: File }) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
}, [file]);
if (!previewUrl) return null;
return <img src={previewUrl} alt="Preview" />;
}User uploads ten images in a row. Ten blob URLs sit in memory. The <img> unmounts but the blob stays pinned.
// ✅ Fixed: revoke when file changes or component unmounts
function ImagePreview({ file }: { file: File }) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!previewUrl) return null;
return <img src={previewUrl} alt="Preview" />;
}This one won't show up in a heap snapshot as a "detached DOM node." It shows up as retained Blob objects. Easy to miss if you're only looking for listener counts.
Part 2: Reference and Architecture Leaks
These don't always look like missing cleanup. The effect returns a function. The listener gets removed. But something in the reference graph still pins memory.
Example 11: Detached DOM Nodes Cached in State
React removes the node from the document. Your code still holds the reference. The entire subtree stays in memory because one variable points at the root.
// ❌ Leaks: removed nodes stay in state
function TooltipManager() {
const [anchors, setAnchors] = useState<Map<string, HTMLElement>>(new Map());
function registerAnchor(id: string, el: HTMLElement | null) {
setAnchors((prev) => {
const next = new Map(prev);
if (el) next.set(id, el);
else next.delete(id);
return next;
});
}
return (
<>
{items.map((item) => (
<TooltipAnchor key={item.id} id={item.id} onRegister={registerAnchor} />
))}
</>
);
}If registerAnchor misses the null call on unmount, or if you snapshot nodes for animation and never clear them, heap snapshots show Detached HTMLDivElement climbing on every route change.
// ✅ Fixed: store ids, query when needed, or clear on unmount
function TooltipAnchor({
id,
onRegister,
}: {
id: string;
onRegister: (id: string, el: HTMLElement | null) => void;
}) {
const ref = useCallback(
(node: HTMLSpanElement | null) => onRegister(id, node),
[id, onRegister],
);
useEffect(() => {
return () => onRegister(id, null);
}, [id, onRegister]);
return <span ref={ref}>...</span>;
}Better still: don't cache DOM nodes in React state at all unless a library requires it. Pass refs down, use getBoundingClientRect at interaction time, or use WeakMap keyed by element if you need a side cache that GC can reclaim when the node dies.
Example 12: Closures Capturing Large Objects
You only need one field from a 10 MB payload. The handler closes over the whole thing. As long as the handler lives, the payload lives.
// ❌ Leaks: closure pins entire dataset
function ExportButton({ dataset }: { dataset: HugeRow[] }) {
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "e" && e.metaKey) {
// only uses dataset.length, but captures all 50k rows
console.log(`Exporting ${dataset.length} rows`);
exportCsv(dataset);
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [dataset]);
return <button>Export</button>;
}Every time dataset changes, the effect re-runs. That's correct. But the old handler might linger until cleanup if something else registered the same pattern incorrectly, and each handler holds the full array in its closure scope.
// ✅ Fixed: extract what you need, use refs for stable handlers
function ExportButton({ dataset }: { dataset: HugeRow[] }) {
const datasetRef = useRef(dataset);
datasetRef.current = dataset;
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "e" && e.metaKey) {
const rows = datasetRef.current;
console.log(`Exporting ${rows.length} rows`);
exportCsv(rows);
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
return <button>Export</button>;
}The stable-ref pattern decouples the listener lifetime from data updates. The listener registers once. It always reads the latest data through the ref. Nothing in the closure captures a stale 10 MB snapshot.
Example 13: requestAnimationFrame Without cancelAnimationFrame
Animation loops and scroll-driven effects often use rAF. Same lifecycle rule as setInterval.
// ❌ Leaks: rAF loop keeps running after unmount
function ParallaxHero() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function tick() {
if (ref.current) {
ref.current.style.transform = `translateY(${window.scrollY * 0.3}px)`;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}, []);
return <div ref={ref}>...</div>;
}Navigate away. The loop keeps scheduling frames. Each frame closes over ref, which closes over the unmounted component's fiber.
// ✅ Fixed: cancel the frame id
function ParallaxHero() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let frameId = 0;
function tick() {
if (ref.current) {
ref.current.style.transform = `translateY(${window.scrollY * 0.3}px)`;
}
frameId = requestAnimationFrame(tick);
}
frameId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameId);
}, []);
return <div ref={ref}>...</div>;
}For scroll-linked work, consider CSS transform with scroll-timeline where supported. Less JS, no rAF loop to forget.
Example 14: Server-Sent Events Without EventSource.close()
SSE is lighter than WebSocket for one-way streams. It's still a connection that needs closing.
// ❌ Leaks: EventSource stays open
function LiveScoreboard({ matchId }: { matchId: string }) {
const [score, setScore] = useState<Score | null>(null);
useEffect(() => {
const source = new EventSource(`/api/matches/${matchId}/stream`);
source.onmessage = (event) => setScore(JSON.parse(event.data));
}, [matchId]);
if (!score) return <Skeleton />;
return <ScoreDisplay score={score} />;
}// ✅ Fixed: close on unmount or matchId change
function LiveScoreboard({ matchId }: { matchId: string }) {
const [score, setScore] = useState<Score | null>(null);
useEffect(() => {
const source = new EventSource(`/api/matches/${matchId}/stream`);
source.onmessage = (event) => setScore(JSON.parse(event.data));
return () => source.close();
}, [matchId]);
if (!score) return <Skeleton />;
return <ScoreDisplay score={score} />;
}Same rule as WebSocket. One connection per effect run. Cleanup closes it. Dependencies list only what triggers a reconnect.
Example 15: Web Workers Without terminate()
Offloading heavy parsing to a worker is good. Leaving the worker alive after the component unmounts is not.
// ❌ Leaks: worker keeps running
function CsvUploader({ file }: { file: File }) {
const [rows, setRows] = useState<ParsedRow[]>([]);
useEffect(() => {
const worker = new Worker(
new URL("./parse-csv.worker.ts", import.meta.url),
);
worker.postMessage(file);
worker.onmessage = (event) => setRows(event.data);
}, [file]);
return <PreviewTable rows={rows} />;
}The worker thread holds the file buffer and posts results back to a dead component.
// ✅ Fixed: terminate on cleanup
function CsvUploader({ file }: { file: File }) {
const [rows, setRows] = useState<ParsedRow[]>([]);
useEffect(() => {
const worker = new Worker(
new URL("./parse-csv.worker.ts", import.meta.url),
);
worker.postMessage(file);
worker.onmessage = (event) => setRows(event.data);
return () => worker.terminate();
}, [file]);
return <PreviewTable rows={rows} />;
}Also remove onmessage before terminating if the worker might post during shutdown. For shared workers used across routes, use a ref-count or a singleton manager instead of creating one per mount.
Example 16: Client-Side Module Cache That Never Evicts
Server module caches get attention. Client module caches in SPAs are the same bug with a different hostname.
// ❌ Leaks: lib/user-cache.ts, grows for the whole session
const profileCache = new Map<string, UserProfile>();
export async function getProfile(userId: string): Promise<UserProfile> {
if (profileCache.has(userId)) return profileCache.get(userId)!;
const profile = await fetchProfile(userId);
profileCache.set(userId, profile);
return profile;
}Every user visited during a long admin session stays in memory. Forever. The tab never navigates away, so the module never reloads.
// ✅ Fixed: bounded cache or WeakRef for soft caching
import { LRUCache } from "lru-cache";
const profileCache = new LRUCache<string, UserProfile>({
max: 100,
ttl: 1000 * 60 * 10,
});
export async function getProfile(userId: string): Promise<UserProfile> {
const cached = profileCache.get(userId);
if (cached) return cached;
const profile = await fetchProfile(userId);
profileCache.set(userId, profile);
return profile;
}For caches keyed by object identity, WeakMap evicts automatically when the key is GC'd. For string keys in a long-lived SPA, you need explicit bounds.
Example 17: CSS-in-JS Dynamic Styles on the Next.js Server
This one burned a production team I read about. Emotion and similar libraries cache generated class names on the server. Dynamic interpolated values create a new class for every unique value.
// ❌ Leaks: new Emotion class per unique width on every SSR request
"use client";
import styled from "@emotion/styled";
const Bar = styled.div<{ width: number }>`
width: ${(p) => p.width}px;
height: 8px;
background: var(--accent);
`;
function FilterChart({ bars }: { bars: { label: string; width: number }[] }) {
return (
<ul>
{bars.map((bar) => (
<li key={bar.label}>
<Bar width={bar.width} />
</li>
))}
</ul>
);
}A filter page with continuous width values generates thousands of unique classes per day. They live in the server process memory. CPU looks fine. Heap climbs until the container OOMs.
// ✅ Fixed: static class, dynamic value inline
"use client";
import styled from "@emotion/styled";
const Bar = styled.div`
height: 8px;
background: var(--accent);
`;
function FilterChart({ bars }: { bars: { label: string; width: number }[] }) {
return (
<ul>
{bars.map((bar) => (
<li key={bar.label}>
<Bar style={{ width: bar.width }} />
</li>
))}
</ul>
);
}One reusable class. Dynamic values go in style={}. Same visual result. Bounded cache growth. If you must interpolate, sanitize to a fixed set of tokens (sm, md, lg) instead of raw pixel values.
Example 18: Store Subscriptions Without Unsubscribe
Zustand, Redux, RxJS, and external data stores all expose subscribe APIs. They follow the same rule as DOM listeners.
// ❌ Leaks: store subscription never removed
import { useSyncExternalStore } from "react";
import { cartStore } from "@/stores/cart";
function CartBadge() {
const [count, setCount] = useState(cartStore.getCount());
useEffect(() => {
cartStore.subscribe(() => {
setCount(cartStore.getCount());
});
}, []);
return <span>{count}</span>;
}Every mount adds another subscriber. The store holds every callback. Each callback closes over setCount from its mount.
// ✅ Fixed: unsubscribe in cleanup
function CartBadge() {
const [count, setCount] = useState(cartStore.getCount());
useEffect(() => {
return cartStore.subscribe(() => {
setCount(cartStore.getCount());
});
}, []);
return <span>{count}</span>;
}Better: use the library's React hook (useStore, useSelector) which handles subscription lifecycle. useSyncExternalStore is the primitive React gives you when you roll your own.
// ✅ Also fixed: useSyncExternalStore (React 18+)
function CartBadge() {
const count = useSyncExternalStore(
cartStore.subscribe,
() => cartStore.getCount(),
() => 0,
);
return <span>{count}</span>;
}No manual effect. No leak. React subscribes and unsubscribes with the component.
Best Practices: Prevention Over Debugging
These aren't new rules. They're the ones that actually stick after you've shipped a leak to production.
1. Treat cleanup as mandatory, not optional
If your useEffect creates any of these, it needs a return function:
- Timers (
setInterval,setTimeout,requestAnimationFrame) - DOM or window listeners
- Observers (
ResizeObserver,IntersectionObserver,MutationObserver) - WebSocket, SSE (
EventSource), or WebRTC connections - Web Workers
- Third-party library instances
- Object URLs
- Store subscriptions (unless using
useSyncExternalStoreor library hooks)
No cleanup? Don't merge it.
2. Split effects by concern
One effect per external process. Don't bundle a resize listener and a polling interval in the same hook. When dependencies change, you want to re-run only what actually changed.
3. Use AbortController for all async work
Fetch, axios, custom promise chains. Abort on unmount. Ignore AbortError in catch. If you're fetching in more than two places, TanStack Query or SWR will save you from writing this by hand.
4. Audit your dependency arrays
A correct cleanup with wrong dependencies is a leak with extra steps. ESLint's react-hooks/exhaustive-deps rule exists for this. Don't disable it without reading every warning.
5. Bound server-side state
Module-level caches need max size and TTL. Database connection pools need proper lifecycle management. Never store per-request data in module scope.
6. Cap unbounded client state
Message feeds, notification lists, undo history, debug logs, module-level Maps. Pick a max. Virtualize long lists. Don't let state grow with session length.
7. Don't store DOM nodes in React state
Query at interaction time, use refs scoped to the component, or WeakMap for element-keyed caches. Detached DOM trees are expensive: one stale ref pins every descendant.
8. Extract only what closures need
If a handler only needs an id, don't close over the full object. Use refs for values that change but shouldn't re-register listeners.
9. Keep dynamic CSS out of styled templates on the server
CSS-in-JS libraries cache class names per unique interpolation. Use static classes with inline style={} for dynamic values in Next.js SSR.
10. Extract leak-proof custom hooks
Once you've written the cleanup pattern twice, abstract it:
function useEventListener<K extends keyof WindowEventMap>(
event: K,
handler: (ev: WindowEventMap[K]) => void,
target: Window | HTMLElement = window,
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const listener = (ev: WindowEventMap[K]) => handlerRef.current(ev);
target.addEventListener(event, listener as EventListener);
return () => target.removeEventListener(event, listener as EventListener);
}, [event, target]);
}The stable-ref pattern keeps the listener from re-registering on every render while always calling the latest handler.
11. Test with long sessions
QA that clicks through five pages won't catch a leak that needs 30 minutes of navigation. Simulate repeated mount/unmount cycles. Leave a tab open overnight in staging. Watch performance.memory in Chrome (it's Chrome-only, but it's enough for local dev).
12. Know your Next.js boundary
| Task | Where it runs | Leak risk |
|---|---|---|
| Data fetch on page load | Server Component (async component) | Low (per-request scope) |
| Polling, WebSocket, listeners | Client Component ("use client") | High (needs cleanup) |
| Shared cache across users | Route Handler module scope | High (needs bounds) |
| CSS-in-JS dynamic styles | Server-rendered Client Components | High (class cache) |
| User-specific cache | cookies() / request-scoped | Low |
Don't put long-lived subscriptions in Server Components. They can't use useEffect and shouldn't hold connections open across requests.
13. Profile before you guess
Chrome DevTools → Memory → Heap snapshot. Take one at page load. Navigate around for two minutes. Take another. Sort by "Retained size" delta. Filter for "Detached" DOM nodes. Look for listener counts climbing in Performance Monitor.
Node.js side: node --inspect node_modules/.bin/next start, connect Chrome DevTools, compare heap snapshots between simulated traffic bursts. Filter for Detached, (closure), and EventListener. Walk the Retainers panel from the leaked object up to the GC root.
If you're on a recent Next.js version and see unbounded ArrayBuffer growth under load, check release notes. Framework-level cache bugs have shipped before. Upgrade before rewriting your app.
Quick Reference
| Pattern | Symptom | Fix |
|---|---|---|
setInterval / setTimeout | CPU usage after navigation | clearInterval / clearTimeout in cleanup |
| Event listeners | Scroll/resize jank, listener count climbs | removeEventListener or AbortSignal |
| Fetch / async | Stale UI, flickering data | AbortController.abort() in cleanup |
| WebSocket / SSE | Connection count grows | close() in cleanup, fix deps |
| Observers | Detached DOM nodes in heap | observer.disconnect() |
| Third-party libs | Canvas/map nodes retained | Library's destroy() / dispose() |
| Server module cache | Node OOM over days | LRU with max + TTL |
| Growing arrays | Slow re-renders over time | Cap, virtualize, or paginate |
| Event emitters | Handlers fire after unmount | Unsubscribe in cleanup |
| Blob URLs | Retained Blob objects | URL.revokeObjectURL() in cleanup |
| Detached DOM cache | Detached HTML*Element in heap | Clear refs on unmount, avoid storing nodes |
| Large closure captures | Memory scales with data size | Refs, extract minimal fields |
requestAnimationFrame | CPU after navigation | cancelAnimationFrame(id) in cleanup |
| Web Workers | Thread + buffer retained | worker.terminate() in cleanup |
| Client module cache | Heap grows over long sessions | LRU, TTL, or WeakMap |
| CSS-in-JS dynamic styles | Server heap grows with traffic | Static classes + inline style={} |
| Store subscriptions | Callback count climbs on remount | Unsubscribe or useSyncExternalStore |
React won't save you from leaks. It gives you the lifecycle hooks to prevent them and Strict Mode to stress-test your symmetry in development. The production tab that climbs to 800 MB is almost never a React bug. It's a timer someone forgot to clear, a DOM node someone cached in state, a closure holding a dataset nobody needs anymore, or a server-side class cache that grows with every unique pixel width.
The rule is simple. If you opened it outside React's tree, close it when the component goes away. If you shouldn't still be pointing at it, stop. Everything in this post is a variation on one of those two sentences.