Lesson 13 / 17
Memory Lifecycle
The phases of allocation, use, and release; reachability's root set; what timers, listeners, and unsettled promises hold onto in memory in asynchronous code.
Contents
The previous topic said “stays in memory” a few times: promises that lost a race, requests that were not canceled, buffers that were never consumed. What stays, why, and for how long was not explained.
This topic explains it. The starting point is the three-phase cycle common to every programming language; what sets JavaScript apart is who owns the third phase.
Three Phases
Allocation. Room is set aside for a value. In JavaScript this is never requested explicitly; it happens on its own when an object, array, string, or function is written.
Use. The allocated room is read and written. This is the program’s actual work.
Release. The room is given back. In languages like C, the program does this; in JavaScript, the runtime does.
The third phase being automatic does not mean the problem disappears; it transforms the problem. In manually managed memory, the error is “releasing too early” — the dangling pointer from the How Computers Work course. In automatic management, the error is “holding a reference longer than necessary.” The first crashes the program, the second bloats it.
Objects live in the heap region of the memory layout introduced in the same course; local variables are kept in a frame on the call stack. The frame disappears when the function returns; an object in the heap becomes collectible once no one refers to it anymore.
Reachability and Roots
The runtime cannot know whether an object is “needed.” Instead, it uses a measurable criterion: reachability. Every object reachable by starting from a root set and following references stays alive; every object unreachable is collectible.
The root set consists of:
- Local variables and parameters on the running call stack
- Bindings in the global scope
- Module-level bindings
- Callbacks of pending timers
- Registered event listeners
- Callbacks of unsettled promises
The last three are this course’s subject and the source of most memory problems. When a timer is set up, its callback and every variable that callback closes over become a root. The closure concept was defined in the Objects and Functions in JavaScript course; the addition here is that a closure’s lifetime is determined by an asynchronous event.
When an Object Becomes Collectible
The rule can be shown with a runnable example. The program below produces two objects: one used only inside a function, the other closed over by a timer callback.
Two tools are used to observe the moment of collection. FinalizationRegistry runs a
callback when a registered object is collected. To manually trigger collection, the
runtime is also started with the relevant option; otherwise the collector runs on its own
schedule and the output is not deterministic.
const registry = new FinalizationRegistry((label) => { console.log("collected:", label); }); function transientMeasurement() { const reading = { station: "A1", value: 21.4 }; registry.register(reading, "transient measurement"); return reading.value; } function heldByTimer() { const reading = { station: "B2", value: 19.8 }; registry.register(reading, "measurement held by timer"); return setTimeout(() => console.log("timer ran:", reading.station), 60); } console.log("transient measurement's value:", transientMeasurement()); const id = heldByTimer(); global.gc(); await new Promise((resolve) => setTimeout(resolve, 10)); global.gc(); console.log("first collection round finished"); clearTimeout(id); global.gc(); await new Promise((resolve) => setTimeout(resolve, 10)); global.gc(); await new Promise((resolve) => setTimeout(resolve, 10)); console.log("round after timer canceled finished");
$ node --expose-gc memory.mjs transient measurement's value: 21.4 collected: transient measurement first collection round finished collected: measurement held by timer round after timer canceled finished
The result shows two rules at once. The first object became unreachable the moment the
function returned and was collected in the first round — what was returned was only a
number, not the object itself. The second object could not be collected as long as the
timer was pending; it was collected only after clearTimeout removed the root.
The --expose-gc option here is for observation only. Manually calling the collector in
application code is neither required nor correct; the decision belongs to the runtime.
Asynchronous Roots and the Measurement Stream
The same rule applies to every construct in the course’s running example.
A self-scheduling stream. The startStream function in the Timers topic sets up a
new timer on every round. As long as the stream is not stopped, the callback and the
counters, buffer, and station list it closes over stay in memory. The function that stops
the stream is at the same time a memory function.
An uncanceled request. In a race-based timeout, the losing promise never settles; because an unsettled promise’s callbacks are also a root, the buffer and response body the request holds cannot be released.
A listener that is not removed. In the Abort Signal lesson, the listener was
registered with the once option. Without that option, the listener would stay bound to
the signal object, which would in turn hold onto everything the callback closes over.
The shared lesson of the three examples is this: starting an asynchronous job is at the same time creating a root. The code that finishes or cancels the job is the code that removes that root.
What a Closure Holds
It is assumed that a closure holds every value in its own scope. Runtimes are not required to behave this way: only the bindings the inner function actually references are kept; the rest are treated like ordinary local variables.
const collected = []; const registry = new FinalizationRegistry((label) => { collected.push(label); }); function setUpClosure() { const rawBuffer = { name: "raw measurement array" }; const summary = { station: "A1", value: 21.4 }; registry.register(rawBuffer, "raw buffer"); registry.register(summary, "summary"); return function giveSummary() { return summary.value; }; } const giveSummary = setUpClosure(); global.gc(); await new Promise((resolve) => setTimeout(resolve, 10)); global.gc(); await new Promise((resolve) => setTimeout(resolve, 10)); console.log("value the closure gives:", giveSummary()); console.log("collected:", collected.sort().join(", ") || "(none)"); console.log("measurement fields:", Object.keys(process.memoryUsage()).sort().join(", "));
$ node --expose-gc closure.mjs value the closure gives: 21.4 collected: raw buffer measurement fields: arrayBuffers, external, heapTotal, heapUsed, rss
Both objects were locals of the same function; the returned closure referenced only one of them. The unreferenced buffer was collected, the referenced summary stayed as long as the closure lived.
Even so, this behavior is not a guarantee; which bindings a closure keeps is the runtime’s decision. The portable rule is: in a closure that will live long, close over only what is necessary. If only a single field is needed from a large structure, building the closure around that field instead of the whole structure is both clearer and safer.
The last line shows which axes memory usage is reported on. Because the values change by
machine and load, only the field names are printed here: rss is the total memory the
process gets from the operating system, heapTotal is the allocated heap size,
heapUsed is the portion actually used in the heap, and external and arrayBuffers
are binary data held outside the JavaScript heap. The field usually watched in leak
tracking is heapUsed.
When Release Becomes Visible
An object becoming unreachable and memory actually being given back are not the same moment. The collector runs on its own schedule; measured memory usage may not drop immediately after unreachability.
This has two practical consequences. First, the observation “memory did not drop” is not
proof of a leak on its own; the measurement has to be repeated across more than one
collection round. Second, when a FinalizationRegistry callback runs — or even whether it
runs at all — is not guaranteed; it may never run while the program is exiting. For this
reason, resource cleanup is not written there; cleanup is done with an explicit close call
or a finally block.
Summary
- Memory’s lifecycle consists of allocation, use, and release phases; in JavaScript the runtime does the third.
- Automatic management eliminates the dangling-pointer error and replaces it with the “holding a reference longer than necessary” error.
- An object becomes collectible once it is unreachable from the root set; pending timers, registered listeners, and unsettled promises are also among the roots.
- Starting an asynchronous job creates a root; cancellation or completion removes that root.
- A closure can hold only the bindings it references; not closing over unnecessary values in long-lived closures is the portable rule.
- Unreachability and memory being given back are not the same moment; finalization callbacks cannot be used for cleanup.
Next Step
In this lesson, the collector was used as a black box: it was said to “collect the unreachable” and was triggered by hand. The next lesson opens the box. How reachability is computed, why the reference-counting method fails on cycles, how the generational hypothesis speeds up the collector, and what gap weak-reference structures fill in this scheme will be covered.
To keep your progress and take notes, Log in
My notes
Log in to take notes.