Timer and Interval Leaks in Long-Running Pages
setInterval is the only common API that installs a permanent garbage-collection root with one line and no visible owner. This page belongs to closure memory leaks in modern JavaScript inside Browser DevTools & Performance Profiling Workflows.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Heap rises by a fixed amount per view visit | An interval created per mount, never cleared | Clear it in the teardown for every exit path |
| Console warns about state updates after unmount | A live callback still holding component state | Clear the timer; the warning is the leak’s echo |
| Multiple identical network requests per tick | Several intervals from repeated mounts | Count outstanding handles before fixing anything |
| Memory grows in a background tab | Throttling reduces work, not retention | Registration is the leak, not execution |
| Animation loop keeps running after navigation | Self-rescheduling requestAnimationFrame |
Cancel the pending frame in the teardown |
Root Cause: A Root With No Visible Owner
When you call setInterval, the browser stores the callback in a task list owned by the event loop. That list is a garbage-collection root, so the callback is permanently reachable, and so is its closure context, and so is everything that context captures — the component instance, the DOM node it referenced, the response body it closed over.
Nothing in the code shows this. There is no variable holding the callback and no object you can inspect; the retaining reference lives in the browser’s scheduler. This is what makes timer leaks distinctive: a snapshot’s retainer chain ends at something with no counterpart in your source, and the only handle you have is the numeric id setInterval returned.
The scale follows from the capture, not from the timer. A callback that reads one integer retains a few bytes. A callback that calls this.setState retains the component, its props, its rendered subtree and anything those reference — routinely a megabyte or more per mount, as covered in the parent guide on closure retention.
Timeouts differ in one respect: they are one-shot, so the retention ends when they fire. That makes a forgotten setTimeout a bounded problem and a forgotten setInterval an unbounded one — but a long timeout, or thousands of short ones created in a loop, closes the gap.
Step-by-Step Fix
- Instrument the timer APIs in development. Verification: the registry reports a non-zero count and each entry carries the stack that created it.
- Cycle the view five times. Expected: a clean view returns to the same count; a leaking one rises by exactly one per cycle, which also tells you it is a per-mount bug rather than a per-action one.
- Follow the capture in a snapshot. DevTools path: Memory → Heap snapshot → filter
Timeror search the callback name → Retainers. Expected: the largest retained object names the real cost. - Clear on every exit path. Including early returns and error paths. Verification: the registry count returns to baseline after the cycle test.
- Prefer a pattern that cannot be forgotten. Verification: new code that creates a timer without registering its teardown fails review or lint.
Command & Code Reference
A development-only registry that turns “is something leaking timers” into a number with a stack trace.
// Load this before application code, in development builds only.
const liveTimers = new Map(); // id → creation stack
const realSetInterval = window.setInterval;
const realClearInterval = window.clearInterval;
window.setInterval = function (fn, ms, ...args) {
const id = realSetInterval.call(window, fn, ms, ...args);
liveTimers.set(id, new Error('created here').stack);
return id;
};
window.clearInterval = function (id) {
liveTimers.delete(id);
return realClearInterval.call(window, id);
};
// In the console after cycling a view five times:
// __liveTimers.size → should equal the baseline
// [...__liveTimers.values()][0] → the stack that created the oldest one
globalThis.__liveTimers = liveTimers;
The pattern that makes forgetting impossible: creation returns its own teardown.
// Every creator hands back a disposer, so the call site cannot hold one
// without the other.
function everyInterval(ms, fn) {
const id = setInterval(fn, ms);
return () => clearInterval(id);
}
function mountDashboard(container) {
const disposers = [
everyInterval(30_000, refreshMetrics),
everyInterval(5_000, pollStatus),
];
// One teardown for the whole view — nothing to enumerate by hand.
return () => disposers.forEach((dispose) => dispose());
}
Cancelling a self-rescheduling animation loop, which behaves like an interval and is missed just as often.
function startLoop(draw) {
let handle = 0;
let running = true;
const tick = (t) => {
if (!running) return; // guard: a queued frame may still fire
draw(t);
handle = requestAnimationFrame(tick);
};
handle = requestAnimationFrame(tick);
return () => {
running = false; // stops rescheduling
cancelAnimationFrame(handle); // cancels the frame already queued
};
}
Verification & Regression Prevention
Verify with the count rather than with the heap. A timer count is an integer that returns to an exact baseline; a heap figure depends on when a collection ran. The assertion “outstanding timers after unmount equals outstanding timers before mount” is deterministic, fast, and fails for exactly one reason.
For prevention, adopt the disposer-returning pattern above so creation and cleanup are physically adjacent, and add a lint rule flagging any setInterval whose return value is discarded. In frameworks, this is the same discipline as effect cleanup — see fixing useEffect cleanup memory leaks — and the framework’s own teardown hook is the right place for the disposer to be called.
FAQ
Does an uncleared setTimeout leak?
Only until it fires. A pending timeout retains its callback and everything the callback captures, so a ten-minute timeout holds that memory for ten minutes, but a setInterval retains it forever. Timeouts still matter when they are long or created in large numbers.
Do background tabs stop timers from leaking?
No. Throttling changes how often a callback runs, not whether the registration exists. A throttled interval in a background tab still retains its closure in full — it simply stops doing work while it does it.
Is requestAnimationFrame safer than setInterval?
Slightly, because a frame callback is one-shot: forgetting to cancel leaks only until the next frame. A self-rescheduling animation loop is exactly as dangerous as an interval, since each frame re-registers the same closure and keeps the chain alive indefinitely.
Related
- Closure Memory Leaks in Modern JavaScript — what the callback’s context actually retains
- How to Find the Closure Retaining an Object in DevTools — following the chain from the timer to the object
- Event Listener Leaks & AbortController Cleanup — the same discipline for listeners