Node.js Memory in AWS Lambda Across Warm Invocations
A Lambda function works for hours, then starts failing with Runtime.OutOfMemory or “signal: killed”, and the Max Memory Used figure in its REPORT lines climbs steadily from invocation to invocation. This guide from Memory Limits and Out-of-Heap Errors in Node.js, in JavaScript Memory Fundamentals & Runtime Mechanics, explains how warm execution environments carry memory between invocations, how to spot a leak from Lambda’s own logs, and how to fix and size functions so memory stays flat.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Max Memory Used rises with each invocation on the same environment |
Module-scope state grows across warm invocations | Group REPORT lines by log stream and plot the trend | Confirms a cross-invocation leak |
Occasional Runtime.OutOfMemory after many successful calls |
Leak reaches the function’s memory size | Bound or clear module-level caches per invocation | Stable memory per environment |
| Leak only under high concurrency of events per invocation | Per-record data accumulated in globals during batch processing | Keep batch data local to the handler | Memory proportional to batch, not history |
| Timers or pending promises from earlier invocations resurface | Frozen environment resumes with work still queued | Await all work; clear timers before returning | No stray work or retained closures |
| Function slow and near memory limit even without a leak | Memory size too small for the workload | Right-size memory (also buys CPU) | Lower duration and no OOM |
Root Cause: The Execution Environment Outlives the Invocation
Lambda runs your handler inside an execution environment: a micro-VM with the Node.js runtime and your code loaded. The first invocation initialises it — module top-level code runs once. After the handler returns, Lambda freezes the environment and, if another event arrives soon, thaws and reuses it for the next invocation. Everything at module scope survives: database clients, SDK clients, caches, and anything else your code stored outside the handler. That reuse is what makes warm invocations fast, and it is also why module state behaves exactly like the long-lived process state described in module-level caches and global singleton leaks.
A function that appends each processed record to a module-level array, memoises by request ID, or registers a listener on a shared client inside the handler leaks a little on every invocation. Because a single environment may serve thousands of invocations before Lambda recycles it, the leak accumulates until the process exceeds the function’s configured memory, and the invocation fails with an out-of-memory error. New environments start fresh, so the failures appear intermittent and uncorrelated with any specific input.
Two Lambda-specific details matter. First, the environment is frozen, not stopped: timers, intervals and pending promises created during one invocation remain queued and may run during a later one, retaining their closures in the meantime. Second, memory size also sets CPU: Lambda allocates CPU in proportion to configured memory, so an under-sized function is also slower, and the heap limit the runtime gives Node is derived from the configured memory. Check it from inside the function rather than assuming it, just as for containers.
Lambda’s REPORT log line at the end of every invocation includes Memory Size and Max Memory Used. Grouping those lines by log stream (each stream corresponds to one execution environment) turns them into a per-environment memory trend — the single most useful signal for this problem.
Step-by-Step Fix
- Extract REPORT lines per environment. In CloudWatch Logs Insights, parse
@maxMemoryUsedfrom REPORT lines and group by@logStream. Verification: you can plot Max Memory Used against time for individual environments. - Distinguish leak from sizing. A leak shows a steady rise within each log stream; a sizing problem shows high but flat usage. Verification: you have classified the issue.
- Audit module scope. List everything declared outside the handler that is mutated inside it: arrays, maps, caches, counters, listener registrations. Verification: each has either a bound or a reason to be permanent (for example a single SDK client).
- Move per-invocation data into the handler. Batch arrays, intermediate results and per-request caches belong inside the handler function, so they are released when it returns. Verification: no per-record data is stored at module scope.
- Finish all work before returning. Await every promise, clear intervals and timeouts created during the invocation, and avoid registering listeners on shared clients per invocation. Verification: after the handler returns,
process.getActiveResourcesInfo()shows no new timers compared with the start of the invocation. - Re-measure and right-size. Deploy, then check per-stream Max Memory Used again, and choose a memory size with comfortable headroom above the flat level. Verification: usage stays flat within each stream, and duration improves if you raised memory.
Command and Code Reference
Use case: find leaking environments from Lambda’s own logs. CloudWatch Logs Insights query over the function’s log group.
filter @type = "REPORT"
| stats max(@maxMemoryUsed / 1000 / 1000) as maxMB,
min(@maxMemoryUsed / 1000 / 1000) as minMB,
count(*) as invocations
by @logStream
| sort maxMB desc
| limit 20
Use case: the leaking handler and the fixed one.
// Leaky: module-level state grows with every warm invocation
const processed = []; // survives between invocations
const client = new DbClient(); // fine: one client per environment
export const handler = async (event) => {
for (const record of event.Records) {
const row = transform(record);
processed.push(row); // grows forever on a warm environment
await client.insert(row);
}
return { count: event.Records.length };
};
// Fixed: per-invocation data stays inside the handler
export const handlerFixed = async (event) => {
const rows = event.Records.map(transform); // released when the handler returns
await client.insertMany(rows);
return { count: rows.length };
};
Use case: log heap usage at the end of each invocation for correlation.
export const handler = async (event, context) => {
try {
return await doWork(event);
} finally {
const { heapUsed, rss } = process.memoryUsage();
// One structured line per invocation; filter by requestId/logStream later
console.log(JSON.stringify({
requestId: context.awsRequestId,
heapMB: Math.round(heapUsed / 1048576),
rssMB: Math.round(rss / 1048576),
limitMB: Number(context.memoryLimitInMB),
}));
}
};
Verification and Regression Prevention
The fix is confirmed when per-environment Max Memory Used stays flat across hundreds of warm invocations, Runtime.OutOfMemory errors disappear, and your per-invocation heap log shows heapMB stable within each log stream. Verify under realistic invocation patterns — a function that receives one event per hour rarely stays warm long enough to reveal the leak, while a busy queue consumer shows it within minutes.
Add a lightweight local test that calls the handler a few thousand times in one process and asserts that heap usage after garbage collection does not grow, using the approach in writing memory leak tests with Vitest and --expose-gc. It reproduces warm-environment behaviour cheaply and catches module-scope growth before deployment. Set a CloudWatch alarm on the maximum @maxMemoryUsed as a percentage of memory size to warn before errors start.
Edge Cases and Gotchas
Caching across invocations is still useful
Reusing clients, connection pools and small reference-data caches across invocations is a legitimate optimisation. Keep such state bounded and independent of request volume, and refresh it with a time-to-live rather than growing it.
Provisioned concurrency keeps environments alive longer
Environments kept warm by provisioned concurrency can serve far more invocations than on-demand ones, so slow leaks that never mattered before can surface after enabling it.
/tmp is memory-adjacent
Files written to /tmp persist across warm invocations too and count toward the environment’s ephemeral storage. Clean up temporary files at the end of each invocation, or they accumulate like module state.
Extensions and layers use memory
Lambda extensions run in the same environment and share its memory. If Max Memory Used is high but your heap log is small, account for extensions and native libraries before assuming a JavaScript leak.
Frequently Asked Questions
Why does Lambda memory grow between invocations?
Because warm invocations reuse the same Node.js process. Anything stored at module scope — arrays, caches, listeners — persists and accumulates. The handler’s local variables are released after each invocation, but module state is not until the environment is recycled.
How do I know if my Lambda function is leaking?
Group REPORT log lines by log stream, since each stream is one execution environment, and look at Max Memory Used over time within a stream. A steady rise within a stream indicates a leak; high but flat usage indicates the memory size is simply tight.
Should I increase the function’s memory to fix OOM errors?
Only if usage is flat and near the limit. If it grows per invocation, more memory merely delays the failure. Fix the leak first, then choose a memory size with headroom — which also increases CPU allocation and often reduces duration.
Do timers keep running between invocations?
The environment is frozen after the handler returns, so timers do not fire while frozen, but they remain queued and can fire during a later invocation. Their closures stay alive meanwhile. Clear timers and await all promises before returning.
Related
- Memory Limits and Out-of-Heap Errors in Node.js — the parent topic
- How Node.js Sizes the Default Heap in Containers — heap limits in constrained environments
- Request-Scoped State with AsyncLocalStorage — keeping per-request data out of module scope
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview