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
supervisor346 lines
import { fork } from 'node:child_process';
import { unlink } from 'node:fs/promises';
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;
const childEnvironmentKeys = [
  'NODE_ENV',
  'TZ',
  'LANG',
  'LC_ALL',
  'TEMP',
  'TMP',
  'TMPDIR',
  'SystemRoot',
  'WINDIR',
];

function isolatedChildEnvironment() {
  return {
    NODE_LOOP_LAB_MEMORY_CHILD: '1',
    ...Object.fromEntries(
      childEnvironmentKeys
        .filter((key) => process.env[key] !== undefined)
        .map((key) => [key, process.env[key]]),
    ),
  };
}

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.snapshotPath = null;
    this.state = {
      status: 'idle',
      pid: null,
      config: null,
      latest: null,
      snapshot: { status: 'idle' },
      lastLog: 'Эксперимент ещё не запускался',
    };
  }

  cleanupSnapshot() {
    const previousPath = this.snapshotPath;
    this.snapshotPath = null;
    if (previousPath) void unlink(previousPath).catch(() => {});
  }

  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;
    }

    this.cleanupSnapshot();
    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'],
      // Heap snapshots can contain strings reachable in the target isolate.
      // Do not let the synthetic lab child inherit application secrets.
      env: isolatedChildEnvironment(),
      windowsHide: true,
    });

    this.child = child;
    this.state = {
      status: 'starting',
      pid: child.pid,
      config,
      latest: null,
      snapshot: { status: 'idle' },
      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);
      } else if (message.type === 'snapshot') {
        if (message.status === 'ready') {
          this.cleanupSnapshot();
          this.snapshotPath = message.path;
          this.state.snapshot = {
            status: 'ready',
            fileName: message.fileName,
            size: message.size,
            createdAt: message.createdAt,
          };
        } else if (message.status === 'error') {
          this.state.snapshot = {
            status: 'error',
            error: message.error,
          };
        } else {
          this.state.snapshot = { status: 'creating' };
        }
        this.broadcast('state', this.snapshot());
      }
    });

    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',
      'snapshot',
      '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;
    }

    if (action === 'snapshot') {
      if (this.state.snapshot?.status === 'creating') {
        const error = new Error('Heap snapshot уже создаётся');
        error.statusCode = 409;
        throw error;
      }
      const retainedMb = (this.state.latest?.retainedBytes ?? 0) / MB;
      const snapshotLimit = this.profile.memory.snapshotMaxRetainedMb;
      if (retainedMb > snapshotLimit) {
        const error = new Error(
          `Сначала уменьшите retained до ${snapshotLimit} MB или ниже: heap snapshot может временно удвоить потребление V8 heap`,
        );
        error.statusCode = 413;
        throw error;
      }
      this.state.snapshot = { status: 'creating' };
      this.broadcast('state', this.snapshot());
    }

    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();
  }

  snapshotDownload() {
    if (
      !this.snapshotPath ||
      this.state.snapshot?.status !== 'ready'
    ) {
      const error = new Error('Сначала создайте heap snapshot');
      error.statusCode = 404;
      throw error;
    }

    return {
      path: this.snapshotPath,
      ...this.state.snapshot,
    };
  }

  stopForShutdown() {
    if (this.child) {
      this.child.kill();
      this.child = null;
    }
    this.cleanupSnapshot();
  }
}

const memoryLabKey = Symbol.for('node-loop-lab.memory');
export const memoryLab =
  globalThis[memoryLabKey] ?? (globalThis[memoryLabKey] = new MemoryLab());
export { MemoryLab, safeConfig };

The application instruments its own live trace: rows and timestamps are recorded by real emit(...) calls, while the scenario supplies source and lane labels. This is not a V8/libuv profiler or a direct view of their internal queues. await and Promise keep the HTTP stream open until the scenario completes.

06 · PRODUCTION CASES

How a learning mistake becomes an incident

A realistic service: the original code, observable failure, corrected implementation, and why the correction works.

CASE 01

A request listener retains the entire upload after the response

A Nest controller subscribes to a shared EventEmitter to record import completion. Its closure captures the DTO and a large parsed CSV after the response has finished.

INCIDENT CONTEXT

The shared emitter remains reachable for the lifetime of the process. Every forgotten listener therefore retains its closure, request, and payload, so heap usage grows after each import.

BEFOREPROBLEMATIC IMPLEMENTATION
@Controller('imports')
export class ImportsController {
  constructor(private readonly importer: ImportService) {}

  @Post()
  async start(@Body() input: ImportCsvDto) {
    const rows = parseCsv(input.csv);

    this.importer.events.on('finished', (event) => {
      if (event.id === input.id) {
        this.importer.audit(input.id, rows.length);
      }
    });

    await this.importer.start(input);
    return { status: 'accepted' };
  }
}

on creates a persistent subscription. Nobody calls off after return, so the closure retains input and the expanded rows array.

AFTERCORRECTED IMPLEMENTATION
@Controller('imports')
export class ImportsController {
  constructor(
    @InjectQueue('csv-import')
    private readonly importQueue: Queue,
  ) {}

  @Post()
  @HttpCode(HttpStatus.ACCEPTED)
  async start(@Body() input: StartImportDto) {
    // The CSV is already stored in object storage.
    const job = await this.importQueue.add(
      'import',
      { objectKey: input.objectKey },
      { jobId: 'import-' + input.id },
    );

    return {
      jobId: job.id,
      statusUrl: '/imports/' + job.id,
    };
  }
}

The HTTP controller no longer creates a long-lived listener. Redis holds a small job with an objectKey, while object storage owns the CSV and a separate worker processes it.

FUNCTIONS AND CONSTRUCTS

What the unfamiliar calls from both code samples actually do.

@Body() input
Nest passes the request DTO. If a closure captures input, its entire related payload remains reachable.
emitter.on(event, listener)
Adds a persistent EventEmitter listener. It remains registered until off or removeListener is called.
parseCsv(input.csv)
A representative parser that expands a large CSV into objects; retaining this array is easy to spot in a heap snapshot.
queue.add(...)
Stores a small background-job description in Redis instead of retaining a request closure in the HTTP process.
objectKey
A small identifier for a file in S3-compatible object storage; the worker downloads the CSV through this key later.
@HttpCode(HttpStatus.ACCEPTED)
Makes Nest return HTTP 202: the work was accepted but will finish asynchronously.
WHY THE CORRECTION WORKS

Garbage collection cannot free a reachable object. For long production work, a durable queue is usually safer than a listener owned by a short HTTP request.

WHAT PRODUCTION SHOWED
  • Heap used does not return to its baseline after imports finish.
  • EventEmitter reports MaxListenersExceededWarning.
  • Snapshot comparison shows many request objects retained by listeners.
07 · 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.

08 · 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?