Memory leak
A controlled leak in an isolated process: watch heap, external, and RSS, release references, and then run GC.
Memory chart
Understanding: Memory leak
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
The garbage collector is like a cleaner who discards only ownerless items. If unused boxes remain listed in a global inventory, the cleaner assumes they are needed. Remove the references first; only then can memory be reclaimed.
GC starts from roots such as globals, active stacks, closures, and internal handles. Everything reachable from those roots is alive. A leak usually means the program accidentally preserves a path from a root to data that is no longer useful. Lab metrics are different, partially overlapping views of memory.
Terms used in this experiment
Understand the words first, then the execution order.
Heap Used
The managed V8 heap occupied by JavaScript objects, arrays, strings, and metadata.
External
Memory connected to JavaScript objects but stored outside the V8 heap, such as Buffer backing stores.
RSS
Total resident process memory: heap, native code, stacks, buffers, and other mapped pages.
Retained
Data kept alive by references. Here it is the controlled estimate of blocks stored in an array.
GC root
A starting point for garbage-collector traversal, such as global scope or an active stack.
Reachable
An object connected to a GC root through references; the collector is not allowed to remove it.
What happens step by step
Each step maps to an observable runtime state.
- 01Manual start
Express creates a separate Node process with a V8 heap cap and application safeguards.
- 02Allocation
A timer creates a Buffer, Array, or mixed block.
- 03Retention
The block reference is pushed into the global retainedBlocks array.
- 04Pause
New blocks stop, but existing blocks remain reachable and occupy memory.
- 05Release + GC
Clearing the array removes the path from a root, so GC can collect the blocks.
- 06Stop
Terminating the child process guarantees that all of its memory returns to the OS.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
Retained is a lab counter
It sums the requested sizes of blocks kept in the array. It is not the exact retained size from a heap snapshot or actual RSS; objects also have runtime overhead.
Metrics partially overlap
arrayBuffers is included in external, while RSS contains heap, external, code, stacks, and other pages. Adding heapUsed + external + arrayBuffers + RSS produces double counting.
Release, GC, and OS return are separate
Removing references only makes objects unreachable. GC later releases them to the allocator, and the allocator may keep pages for reuse, so RSS does not have to fall immediately.
A safeguard is not always a hard quota
Retained, RSS, and time thresholds are enforced by code, while --max-old-space-size limits V8 heap rather than total process memory. The Docker cgroup in compose.yml provides a hard 2 GB total for server plus child.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
let leakedBlocks = [];
setInterval(() => {
// The reference remains reachable from global scope:
leakedBlocks.push(Buffer.alloc(4 * 1024 * 1024));
console.log(process.memoryUsage());
}, 500);
// Remove the references first:
leakedBlocks = [];
// Only now can GC reclaim the objects.The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
import { fork } from 'node:child_process';
import path from 'node:path';
import { labProfile } from './lab-profile.js';
const childPath = path.resolve(
process.env.NODE_LOOP_SOURCE_DIR ||
path.join(/* turbopackIgnore: true */ process.cwd(), 'src'),
'memory-leak-child.js',
);
const MB = 1024 * 1024;
function safeConfig(input = {}, profile = labProfile) {
const memory = profile.memory;
const defaultConfig = memory.defaultConfig;
return {
kind: memory.kinds.includes(input.kind) ? input.kind : defaultConfig.kind,
allocationMb: memory.allocationMb.includes(Number(input.allocationMb))
? Number(input.allocationMb)
: defaultConfig.allocationMb,
intervalMs: memory.intervalMs.includes(Number(input.intervalMs))
? Number(input.intervalMs)
: defaultConfig.intervalMs,
limitMb: memory.limitMb.includes(Number(input.limitMb))
? Number(input.limitMb)
: defaultConfig.limitMb,
};
}
class MemoryLab {
constructor(profile = labProfile) {
this.profile = profile;
this.child = null;
this.clients = new Set();
this.stopTimer = null;
this.state = {
status: 'idle',
pid: null,
config: null,
latest: null,
lastLog: 'Эксперимент ещё не запускался',
};
}
snapshot() {
return structuredClone(this.state);
}
broadcast(event, data) {
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of this.clients) {
try {
client.controller.enqueue(client.encoder.encode(frame));
} catch {
client.close();
}
}
}
createEventStream(signal) {
if (this.clients.size >= this.profile.api.maxSseClients) {
const error = new Error(
'Достигнут лимит подключений к потоку memory-lab',
);
error.statusCode = 503;
throw error;
}
const lab = this;
let client;
return new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
let closed = false;
const close = () => {
if (closed) return;
closed = true;
clearInterval(client.heartbeat);
lab.clients.delete(client);
try {
controller.close();
} catch {
// Поток уже закрыт браузером.
}
};
client = { controller, encoder, close, heartbeat: null };
lab.clients.add(client);
controller.enqueue(
encoder.encode(
`event: state\ndata: ${JSON.stringify(lab.snapshot())}\n\n`,
),
);
client.heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(': keep-alive\n\n'));
} catch {
close();
}
}, 15_000);
client.heartbeat.unref?.();
signal?.addEventListener('abort', close, { once: true });
},
cancel() {
client?.close();
},
});
}
start(input) {
if (this.child) {
const error = new Error('Эксперимент уже запущен');
error.statusCode = 409;
throw error;
}
const config = safeConfig(input, this.profile);
const safety = {
retainedLimitMb: this.profile.memory.retainedLimitMb,
hardRssLimitMb: this.profile.memory.hardRssLimitMb,
maxDurationMs: this.profile.memory.maxDurationMs,
deadlineAction: this.profile.memory.deadlineAction,
};
const child = fork(/* turbopackIgnore: true */ childPath, [], {
execArgv: [
'--expose-gc',
`--max-old-space-size=${this.profile.memory.v8HeapLimitMb}`,
],
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
windowsHide: true,
});
this.child = child;
this.state = {
status: 'starting',
pid: child.pid,
config,
latest: null,
lastLog: 'Запускаем изолированный процесс…',
};
this.broadcast('state', this.snapshot());
child.on('message', (message) => {
if (message.type === 'sample') {
this.state.status = message.status;
this.state.latest = {
elapsedMs: message.elapsedMs,
retainedBytes: message.retainedBytes,
blocks: message.blocks,
memory: message.memory,
reason: message.reason,
};
this.broadcast('sample', this.snapshot());
// Дочерний процесс также проверяет этот предел сам. Дублирование в
// supervisor защищает лабораторию при ошибке учебного сценария.
if (
message.memory.rss >
this.profile.memory.hardRssLimitMb * MB
) {
this.state.lastLog =
'Supervisor остановил процесс: превышен аварийный предел RSS';
this.broadcast('log', {
level: 'error',
message: this.state.lastLog,
});
child.kill();
}
} else if (message.type === 'log') {
this.state.lastLog = message.message;
this.broadcast('log', message);
}
});
child.stderr.on('data', (chunk) => {
const message = chunk.toString().trim();
if (!message) return;
this.state.lastLog = message;
this.broadcast('log', { level: 'error', message });
});
child.on('error', (error) => {
this.state.lastLog = error.message;
this.broadcast('log', { level: 'error', message: error.message });
});
child.on('exit', (code, signal) => {
clearTimeout(this.stopTimer);
this.stopTimer = null;
this.child = null;
this.state.status = 'stopped';
this.state.pid = null;
this.state.lastLog =
code === 0
? 'Изолированный процесс остановлен'
: `Процесс завершился: code=${code ?? '—'}, signal=${signal ?? '—'}`;
this.broadcast('state', this.snapshot());
this.broadcast('log', {
level: code === 0 ? 'info' : 'error',
message: this.state.lastLog,
});
});
child.send({ type: 'start', config: { ...config, safety } });
return this.snapshot();
}
action(action) {
const allowed = new Set(['pause', 'resume', 'release', 'gc', 'stop']);
if (!allowed.has(action)) {
const error = new Error('Неизвестное действие');
error.statusCode = 400;
throw error;
}
if (!this.child?.connected) {
const error = new Error('Сначала запустите эксперимент');
error.statusCode = 409;
throw error;
}
this.child.send({ type: 'action', action });
if (action === 'stop') {
clearTimeout(this.stopTimer);
this.stopTimer = setTimeout(() => {
if (this.child) this.child.kill();
}, 1000);
this.stopTimer.unref();
}
return this.snapshot();
}
stopForShutdown() {
if (this.child) {
this.child.kill();
this.child = null;
}
}
}
const memoryLabKey = Symbol.for('node-loop-lab.memory');
export const memoryLab =
globalThis[memoryLabKey] ?? (globalThis[memoryLabKey] = new MemoryLab());
export { MemoryLab, safeConfig };
The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.
Common misconceptions
The myth is on the left; the accurate model is on the right.
Any RSS increase proves a leak.
An allocator may retain free pages for reuse. Look for a sustained trend under repeatable load.
global.gc() can remove any object I no longer want.
GC does not understand business intent. A reachable object is alive.
Stable heapUsed means there is no leak.
Buffer/external, native allocations, handles, or other non-heap resources may still grow.
const keeps an object alive forever.
Lifetime is determined by reachability, not by the let or const keyword.
Adding every displayed number gives process memory.
The metrics have different boundaries and overlap. RSS is the total resident view; the other metrics help explain its composition.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- Why does GC before clearing the array not reduce retained memory?
- Why may RSS remain high after successful cleanup?
- Which metric best reveals a Buffer leak?