В среде серверного рендеринга (SSR) рантайм Node.js принципиально отличается от изолированной браузерной среды пользователя. В браузере жизненный цикл сессии ограничен одной вкладкой, и при её закрытии операционная система мгновенно освобождает всю занятую память. На сервере единственный долгоживущий процесс Node.js обслуживает миллионы последовательных и параллельных клиентских запросов.
Любая ссылка на объект, контекст запроса или замыкание, непреднамеренно сохраненная в глобальной области видимости (Root Scope), превращается в утечку памяти. В условиях High-Load это приводит к прогрессивному росту кучи V8 (V8 Heap), частым и затяжным паузам сборщика мусора (Stop-The-World GC Pauses) с деградацией задержек до сотен миллисекунд и в конечном счете — к аварийной перезагрузке контейнера по ошибке OOM (Out Of Memory: JavaScript heap out of memory).
┌─────────────────────────────────────────────────────────────────────────────┐
│ V8 HEAP MEMORY SPACES │
│ │
│ [New Space (Semi-Spaces: From / To)] ──► Scavenge GC (быстрая очистка) │
│ │ │
│ ▼ (Объекты пережили несколько циклов) │
│ [Old Pointer Space] ──► Удерживает ссылки на живые объекты │
│ [Old Data Space] ──► Сырые данные (строки, числа) │
│ │ │
│ ▼ │
│ [Major GC: Mark-Sweep-Compact] ◄── Stop-The-World пауза процесса │
│ │ │
│ ЕСЛИ ССЫЛКА УДЕРЖИВАЕТСЯ GC ROOT ──► Память НЕ освобождается (УТЕЧКА) │
└─────────────────────────────────────────────────────────────────────────────┘Для поддержания стабильности SSR-серверов в Next.js 16 требуется глубокое понимание профилирования кучи V8 через нативный модуль node:v8, инспекции дампов в Chrome DevTools и конфигурации внутреннего кэша cacheMaxMemorySize.
Ментальная модель сборщика мусора V8 основана на достижимости от корней (GC Roots Reachability). Корнями являются глобальный объект globalThis, активный стек вызовов текущего потока и статические свойства зарегистрированных модулей.
IncomingMessage, headers, cookies) была захвачена замыканием внутри глобального EventEmitter, глобального массива или пула подключений, весь граф связанных объектов считается достижимым.[GC Root: Global EventBus]
│
▼ holds reference
[EventListener Closure] (Shallow Size: 48 bytes)
│
▼ holds closure context
[RenderExecutionContext]
│
├──► [RSC Component Tree] (Retained: 25 MB)
├──► [Server Action Cache]
└──► [Full HTTP Request Body]Отслеживание heapUsed и heapTotal через process.memoryUsage() под синтетической нагрузкой autocannon.
Программный съем дампов до и после нагрузки с помощью v8.getHeapSnapshot() без остановки продакшена.
Загрузка .heapsnapshot в Chrome DevTools, сортировка по Retained Size и фильтрация объектов дельты.
Анализ цепочки ссылок от GC Root до утекшего контекста страницы или замыкания.
Ограничение cacheMaxMemorySize в next.config.ts и оптимизация Keep-Alive сокетов HTTP-агентов.
При профилировании SSR-сервера инженер обнаружил, что массив globalThis.subscribers содержит объекты функций-колбэков весом 64 байта каждый. Однако память процесса растет на 200 МБ каждые 10 минут. Почему маленькие колбэки приводят к аварии памяти OOM?
В крупных медиа-порталах и интернет-магазинах с трафиком 10 000+ RPS инстансы Next.js в Kubernetes циклически перезагружались каждые 4-6 часов с кодом ExitCode: 137 (OOMKilled). Профилирование выявило сохранение метаданных пользовательских сессий в статическом singleton-классе авторизации. Очистка ссылок стабилизировала потребление памяти на уровне менее 450 МБ на под.
При активном обращении из Server Components к внутренним gRPC и REST сервисам отсутствие ограничения на максимальное количество открытых сокетов (maxSockets) в HTTP Keep-Alive агентах приводило к накоплению сотен «висячих» дескрипторов соединений (ClientRequest и TLSSocket), удерживающих в куче многомегабайтные сетевые буферы.
По умолчанию серверный кэш компонентов и страниц ("use cache", ISR) может бесконтрольно разрастаться в оперативной памяти сервера при высокой вариативности параметров запроса. Точная настройка директивы cacheMaxMemorySize: 50 * 1024 * 1024 ограничивает объем кэша 50 МБ с алгоритмом вытеснения LRU (Least Recently Used).
В продакшене невозможно подключить Chrome DevTools через открытый порт --inspect из соображений информационной безопасности и блокировки сетевого интерфейса. Настройка защищенного внутреннего эндпоинта или перехватчика сигнала SIGUSR2, вызывающего v8.getHeapSnapshot(), позволяет записать снимок памяти в файл и передать в S3 для последующего детального офлайн-анализа.
Для снятия снимка кучи без использования отладчика инженерам доступен встроенный модуль node:v8. Потоковая запись снимка в файл минимизирует пиковое потребление памяти в процессе создания дампа:
import v8 from 'node:v8';
import fs from 'node:fs';
import path from 'node:path';
/**
* Программное создание дампа кучи V8 для офлайн-анализа.
* Использование потоковой передачи предотвращает дублирование буфера в памяти.
*/
export async function captureHeapSnapshot(tag: string = 'manual'): Promise<string> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `heap-${tag}-${process.pid}-${timestamp}.heapsnapshot`;
const dumpPath = path.join(process.cwd(), 'dumps', filename);
// Создание директории при необходимости
fs.mkdirSync(path.dirname(dumpPath), { recursive: true });
return new Promise((resolve, reject) => {
try {
// Получение нативного потока снимка кучи V8
const snapshotStream = v8.getHeapSnapshot();
const fileStream = fs.createWriteStream(dumpPath);
snapshotStream.pipe(fileStream);
fileStream.on('finish', () => {
const mem = process.memoryUsage();
console.info(`[Heap Profiler] Снимок сохранен: ${dumpPath}`);
console.info(`[Heap Profiler] HeapUsed: ${(mem.heapUsed / 1024 / 1024).toFixed(2)} MB`);
resolve(dumpPath);
});
fileStream.on('error', (err) => {
reject(err);
});
} catch (err: unknown) {
reject(err);
}
});
}Для предотвращения утечек в высоконагруженном SSR-рантайме настраивается ограничение кэша страниц и оптимизированный undici / HTTP Keep-Alive агент:
import type { NextConfig } from 'next';
import http from 'node:http';
import https from 'node:https';
// Оптимизированные глобальные агенты с пулом сокетов и строгим тайм-аутом
export const httpAgent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 10000,
maxSockets: 64,
maxFreeSockets: 16,
timeout: 30000,
});
export const httpsAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 10000,
maxSockets: 64,
maxFreeSockets: 16,
timeout: 30000,
});
const nextConfig: NextConfig = {
// Ограничение серверного in-memory кэша (LRU) до 64 МБ
cacheMaxMemorySize: 64 * 1024 * 1024,
// Генерация изолированного автономного сервера для контейнеризации
output: 'standalone',
experimental: {
// Включение сборщика мусора для неиспользуемых серверных модулей
serverSourceMaps: false, // Отключение тяжелых source maps в памяти продакшена
},
};
export default nextConfig;// src/services/telemetry-bus.ts
import { EventEmitter } from 'node:events';
// ГЛОБАЛЬНЫЙ СИНГЛТОН (Живет все время работы процесса)
export const globalTelemetryBus = new EventEmitter();
globalTelemetryBus.setMaxListeners(0); // ОПАСНО: маскирует предупреждение об утечке
// src/app/dashboard/page.tsx (Server Component)
import { globalTelemetryBus } from '@/services/telemetry-bus';
export default async function DashboardPage() {
const requestTimestamp = Date.now();
const requestScopedData = {
largePayload: new Array(100_000).fill('ssr_sensitive_state'),
};
// КАТАСТРОФА: Подписка добавляется на КАЖДЫЙ входящий HTTP-запрос!
// Слушатель удерживает requestScopedData в лексическом замыкании навсегда!
globalTelemetryBus.on('app_metric', () => {
console.log(`Rendered at ${requestTimestamp}`, requestScopedData.largePayload.length);
});
return (
<main>
<h1>Панель аналитики</h1>
</main>
);
}Прогрев инстанса 100 запросами, принудительный запуск GC через global.gc() и фиксация базового дампа памяти.
Позволяет исключить структуры начальной инициализации модулей.
Подача синтетической нагрузки: 50 000 запросов с 50 параллельными соединениями к целевым SSR-страницам.
Фиксируется прогрессивный рост heapUsed без стабилизации.
Вызов v8.getHeapSnapshot() после завершения нагрузки и отработки очередного цикла сборки мусора.
Дамп содержит накопившиеся неудаленные объекты.
Переключение представления в режим 'Objects allocated between snapshots 1 and 2', сортировка по Retained Size.
Инженер идентифицирует цепочку ссылок от GC Root к замыканию.
После всплеска трафика на SSR-сервер память Node.js не возвращается к норме, а в дампе кучи V8 доминируют тысячи экземпляров Socket и HTTPParser с гигантским Retained Size. При этом запросы к базе данных давно завершены. Что вызвало утечку ресурсов?
Объявление const cache = new Map() на уровне модуля для сохранения результатов дорогостоящих вычислений приводит к фатальной утечке памяти. Поскольку модуль загружается в память процесса один раз, коллекция Map удерживает ключи и значения вечно. Без алгоритма автоматической инвалидации и вытеснения (LRU Cache с фиксированным размером) размер коллекции непрерывно растет при каждом уникальном запросе пользователя.
Попытка выгрузить снапшот памяти объемом 1-2 ГБ синхронным методом замораживает Event Loop Node.js на несколько секунд. В продакшене это приводит к сбою Health Check проб Kubernetes (livenessProbe / readinessProbe), признанию пода мертвым и его принудительному перезапуску посреди записи дампа. Используйте строго потоковую запись через pipe(fileStream).
Запуск таймеров setInterval внутри тела серверного компонента или функции обработки запроса без гарантированного вызова clearInterval создает постоянную ссылку на функцию таймера в таблице активных дескрипторов Libuv. Пока таймер активен, сборщик мусора не может освободить его замыкание, что приводит к линейной утечке памяти на каждый запрос.
Распространенная паллиативная мера — увеличение лимита памяти V8 с 2 ГБ до 8 ГБ флагом --max-old-space-size=8192. Это не решает проблему утечки, а лишь оттягивает момент падения, параллельно увеличивая время пауз Major GC до нескольких секунд, что приводит к масштабным тайм-аутам клиентских запросов.