NNODE LOOP LABruntime observatoryNNEON · Articles in 90 languages
CONNECTING
v24.18.0linux/x64
Retained references → rising RSS
06

Memory leak

A controlled leak in an isolated process: watch heap, external, and RSS, release references, and then run GC.

PROCESS IDcurrent server
UPTIMEsince startup
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Memory chart

READY
PUBLIC SAFE
Public mode · forced termination

Maximum 256 MB retained and 512 MB RSS. After 60 seconds the child process exits and returns its memory to the container.

RETAINED0.0 MB0 blocks
HEAP USED0.0 MBV8 objects
EXTERNAL0.0 MBBuffer / ArrayBuffer
CHILD RSS0.0 MBPID —
MEMORY OVER TIME
retainedheapexternalrss
0sscale 128 MBnow
The experiment is off. No memory is being allocated.
CHAPTER 06
DEEP DIVE · FROM BASICS TO CODE

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.

FIRST, IN PLAIN LANGUAGE

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.

TECHNICAL FOUNDATION

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.

Why it mattersLong-term growth causes more frequent and longer GC pauses, swapping, slowdown, and eventually out-of-memory termination. Different memory types appear in different metrics.
WHERE THE WORK RUNS
01YOUR JS CODEfunctions · callbacks
02NODE APIsfs · crypto · timers
03V8 + LIBUVheap · loop · pool
04OPERATING SYSTEMI/O · threads · memory
01 · GLOSSARY

Terms used in this experiment

Understand the words first, then the execution order.

01

Heap Used

The managed V8 heap occupied by JavaScript objects, arrays, strings, and metadata.

02

External

Memory connected to JavaScript objects but stored outside the V8 heap, such as Buffer backing stores.

03

RSS

Total resident process memory: heap, native code, stacks, buffers, and other mapped pages.

04

Retained

Data kept alive by references. Here it is the controlled estimate of blocks stored in an array.

05

GC root

A starting point for garbage-collector traversal, such as global scope or an active stack.

06

Reachable

An object connected to a GC root through references; the collector is not allowed to remove it.

02 · MECHANICS

What happens step by step

Each step maps to an observable runtime state.

  1. 01
    Manual start

    Express creates a separate Node process with a V8 heap cap and application safeguards.

  2. 02
    Allocation

    A timer creates a Buffer, Array, or mixed block.

  3. 03
    Retention

    The block reference is pushed into the global retainedBlocks array.

  4. 04
    Pause

    New blocks stop, but existing blocks remain reachable and occupy memory.

  5. 05
    Release + GC

    Clearing the array removes the path from a root, so GC can collect the blocks.

  6. 06
    Stop

    Terminating the child process guarantees that all of its memory returns to the OS.

03 · CONTEXT

Where the result needs context

These details explain why similar code can sometimes produce a different trace.

01

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.

02

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.

03

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.

04

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.

01
Theory

First understand which parts of Node participate in execution.

02
Simplified code

Then remove instrumentation and focus on the central mechanism.

03
Runtime code

Finally match the model to the code that produces the live trace.

04 · Simplified code

A minimal model without instrumentation

src/demos.js · educational snippetJavaScript
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.
05 · Runtime code

The complete code executed by the scenario

This is not an alternative example: these are the functions and files used by the Run button.

ACTUAL SOURCE

The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.

src/memory-lab.js
supervisor248 lines
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.

06 · DO NOT CONFUSE

Common misconceptions

The myth is on the left; the accurate model is on the right.

MYTH

Any RSS increase proves a leak.

ACTUALLY

An allocator may retain free pages for reuse. Look for a sustained trend under repeatable load.

MYTH

global.gc() can remove any object I no longer want.

ACTUALLY

GC does not understand business intent. A reachable object is alive.

MYTH

Stable heapUsed means there is no leak.

ACTUALLY

Buffer/external, native allocations, handles, or other non-heap resources may still grow.

MYTH

const keeps an object alive forever.

ACTUALLY

Lifetime is determined by reachability, not by the let or const keyword.

MYTH

Adding every displayed number gives process memory.

ACTUALLY

The metrics have different boundaries and overlap. RSS is the total resident view; the other metrics help explain its composition.

07 · SELF-CHECK

Explain it in your own words

If you can explain the answer without quoting documentation, your mental model is starting to take shape.

  1. Why does GC before clearing the array not reduce retained memory?
  2. Why may RSS remain high after successful cleanup?
  3. Which metric best reveals a Buffer leak?