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.

Max Memory Used across warm invocations of one environment For one execution environment of a 512 megabyte function, Max Memory Used starts at 140 megabytes after the cold start and rises by about 0.3 megabytes per invocation because processed records are pushed into a module-level array. After roughly 1,200 invocations it reaches 512 megabytes and the invocation fails with Runtime.OutOfMemory. After the fix, Max Memory Used stays near 150 megabytes. Memory Size 512 MB 512 0 OOM records pushed to a module-level array records kept local to the handler invocations on one warm environment (0 → ~1,200)

Step-by-Step Fix

  1. Extract REPORT lines per environment. In CloudWatch Logs Insights, parse @maxMemoryUsed from REPORT lines and group by @logStream. Verification: you can plot Max Memory Used against time for individual environments.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. 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.
What survives between invocations A cold start runs module top-level code once, creating clients and any module-level state. Each invocation runs only the handler; its local variables are released when it returns. Between invocations the environment is frozen, not stopped, so module-level state, pending timers and unresolved promises persist into the next invocation. Cold start module code runs once Invocation 1 handler locals Frozen timers, promises wait Invocation 2 same process module scope persists across all of these: clients, caches, arrays, registered listeners

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.

Max Memory Used across warm invocations Per execution environment, Max Memory Used rose with each warm invocation while module-scope state accumulated, ending in Runtime.OutOfMemory. After moving per-request state inside the handler and bounding module-scope caches, it stays flat across hundreds of warm invocations. Max Memory warm invocations in one environment module-scope state accumulates per-request state, bounded caches

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.