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.

The retaining chain behind a live interval The event loop's timer list holds the interval callback. The callback's closure context captures the component instance, which holds its props and its rendered DOM subtree. None of these links appear anywhere in application source, and all of them survive unmount. Four links, none of which exist in your code event loop timer list · a GC root callback () => this.refresh() closure context captures `this` component destroyed at t=3 s props · state · detached DOM subtree · 1.4 MB retained per mount, for as long as the page lives clearInterval removes the first link, and the whole chain collapses.

Step-by-Step Fix

  1. Instrument the timer APIs in development. Verification: the registry reports a non-zero count and each entry carries the stack that created it.
  2. 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.
  3. Follow the capture in a snapshot. DevTools path: Memory → Heap snapshot → filter Timer or search the callback name → Retainers. Expected: the largest retained object names the real cost.
  4. Clear on every exit path. Including early returns and error paths. Verification: the registry count returns to baseline after the cycle test.
  5. 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
  };
}
Outstanding timers across eight view cycles Before the fix the outstanding interval count rises by two per cycle to sixteen and retained memory reaches twenty-two megabytes. After the fix the count returns to the baseline of one after every cycle and retained memory stays at one point four megabytes. The count is the signal; the megabytes are the consequence 0 8 16 mount / unmount cycles → before: +2 intervals per cycle → 22 MB retained after: flat at the layout's single timer A test asserting “outstanding count after unmount equals the count before mount” catches this in milliseconds.

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.

How long forgetting to clean up costs you setInterval retains forever. A self-rescheduling animation frame retains forever. A single setTimeout retains until it fires. A single requestAnimationFrame retains until the next frame. Only the first two are unbounded. Only two of these four are unbounded — but they are the two people write most setInterval retains forever — the registration never expires on its own self-rescheduling requestAnimationFrame retains forever — each frame re-registers the same closure single setTimeout retains until it fires — a problem when the delay is long or the count is large single requestAnimationFrame retains until the next frame — effectively harmless on its own

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.