What it means
Node processes run inside two nested memory limits, and confusing them is why this error is so often "fixed" by a change that does nothing. The outer limit is Lambda's: the container's cgroup, enforced by the kernel with SIGKILL. The inner limit is V8's JavaScript heap, which the engine manages itself and sizes from the memory it can see.
When the inner limit is reached first, V8 does something the kernel never does — it tells you. It prints a summary of the last few garbage collections, a fatal error naming the heap, and a stack trace, then aborts. That is genuinely more helpful than an OOM kill, because you get a line number. The cost is that it can happen while the container still has memory free, which is why a REPORT line can show Max Memory Used comfortably under Memory Size on an invocation that died complaining about memory.
The <--- Last few GCs ---> block that precedes the fatal error is worth reading rather than scrolling past. It shows collection cycles running back to back and reclaiming almost nothing, which distinguishes the two shapes of memory failure. A slow accumulation — a cache that never evicts, a result array that grows with every page — produces exactly that pattern of increasingly desperate collections. A single enormous allocation, like parsing one very large document, tends to fail immediately with no GC thrashing at all. The first calls for bounding what you retain; the second calls for streaming what you read.
Both are worth diagnosing against the whole invocation rather than the fatal line alone. The error tells you the heap filled; your own log lines immediately before it tell you what was being loaded when it did, and the REPORT line tells you whether the container had headroom to spare. Those three facts sit in three different places in a raw CloudWatch stream, and they only mean something together.
CloudWatch’s Errors metric: The error escaped your handler, so Lambda reports the invocation as failed and CloudWatch’s Errors metric counts it. V8 aborts the process, so the invocation fails and CloudWatch counts it. Note that LogStitch classified the example below from its log level rather than from an extracted error type, because the runtime writes this as a plain ERROR line rather than a Runtime. envelope.
What it looks like in CloudWatch
This is the shape the failure arrives in: the lines of one invocation scattered among everything else the log group received at the same moment.
CloudWatch Logs Before
7 raw lines, in the order CloudWatch delivered them.
LogStitch After
The same lines, grouped into the invocation they belong to.
ErrorMemory ceiling91b4c7d0-3e55-4a18-b2f6-0d7c9a1e4b33
- 07:14:31.882PLAT
START RequestId: 91b4c7d0-3e55-4a18-b2f6-0d7c9a1e4b33 Version: $LATEST
- 07:14:31.886INFO
INFO Aggregating events window=24h
- 07:14:33.401INFO
INFO Page 1 loaded rows=50000
- 07:14:35.902INFO
INFO Page 2 loaded rows=50000
- 07:14:38.117ERROR
ERROR FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
- 07:14:38.402PLAT
END RequestId: 91b4c7d0-3e55-4a18-b2f6-0d7c9a1e4b33
- 07:14:38.402PLAT
REPORT RequestId: 91b4c7d0-3e55-4a18-b2f6-0d7c9a1e4b33 Duration: 6520.11 ms Billed Duration: 6521 ms Memory Size: 1024 MB Max Memory Used: 1024 MB Status: error
The panel on the right is generated by running the excerpt on the left through the same parser that powers the free web stitcher — it is what the tool actually produces for this input, not an illustration of it.
How to confirm it from the logs
Look for the FATAL ERROR line, and for the <--- Last few GCs ---> block that V8 prints immediately before it. That block is the giveaway: it is V8 reporting that garbage collection ran repeatedly and reclaimed almost nothing, which is the definition of heap exhaustion rather than a sudden spike.
Then compare with the REPORT line. If Max Memory Used is below Memory Size, V8's heap limit was the binding constraint, not Lambda's — the container had room left and the engine still gave up. If they are equal, both ceilings were reached at once.
Causes, most likely first
An array or object grows without bound inside the handler
Find the last line logged before the fatal error and look for accumulation in the code that follows — pushing every row of a query into an array, concatenating strings in a loop, or collecting results across pages before returning. If the crash happens later on larger inputs and not at all on small ones, the growth is proportional to the input.
A whole file or response is parsed into memory at once
Check for JSON.parse on a large body, await response.text(), or reading an S3 object into a string or buffer. Parsing doubles the peak briefly, because the raw text and the resulting object structure both exist at the same moment — a 200 MB JSON document needs well over 400 MB to parse.
Module-scope state accumulates across warm invocations
See whether the failure happens on a container that has already served many requests rather than on a cold start. Anything declared outside the handler persists between invocations; an unbounded cache or a growing array at module scope will eventually fill the heap no matter how small each individual request is.
The allocation is small enough that V8's default heap is the real limit
Check the Memory Size. On smaller allocations V8's old-space limit lands below the container limit, so Max Memory Used never reaches Memory Size and the container never appears to be under pressure — yet the heap is full. A REPORT line showing plenty of unused memory alongside a heap fatal is this case.
Fixes
Raise the function's memory, which raises V8's heap with it
V8 derives its heap limit from the memory available to the container, so increasing MemorySize increases the JavaScript heap without touching any code. This is the correct first move when the working set is genuinely that large, and it buys CPU at the same time.
yamlResources:
TransformFunction:
Type: AWS::Serverless::Function
Properties:
MemorySize: 2048 # was 512; heap scales with the container
Stream the data instead of holding it
The durable fix is to stop peak memory depending on input size. Process records as they arrive so only one chunk is resident at a time, rather than materialising the whole payload and then iterating it.
jsimport { createInterface } from "node:readline";
// Before: the entire body, then the entire parsed array.
// const rows = JSON.parse(await body.transformToString());
// After: one record resident at a time.
const lines = createInterface({ input: body });
for await (const line of lines) {
await handle(JSON.parse(line));
}
Set the heap limit explicitly when you need it below the container size
Occasionally you want V8 to fail early and predictably rather than push the container to an OOM kill — a heap fatal at least leaves a stack trace, where SIGKILL leaves nothing. --max-old-space-size sets the limit in megabytes.
yamlResources:
TransformFunction:
Type: AWS::Serverless::Function
Properties:
MemorySize: 2048
Environment:
Variables:
# Fail with a trace at 1.5 GB rather than being killed at 2 GB.
NODE_OPTIONS: --max-old-space-size=1536
Also seen as
The same underlying failure, worded differently by a different runtime, SDK version, or logging layer. All of these land here — there is no separate page for each phrasing.
Related errors
Errors that show up alongside this one, or that people mistake for it.
References
LogStitch finds this automatically, across every invocation in your account.
Paste a log excerpt into the free web stitcher and see it grouped, classified, and measured in your browser — nothing is uploaded. Or run the Mac app against your own AWS profiles and get the same view over every function you own.