What it means
A rejected promise carries an error to whoever is waiting for it. When nobody is waiting, Node has no one to hand it to, and since version 15 the default response is to treat that as fatal: the process raises unhandledRejection and terminates. Lambda's Node runtime catches this and reports the invocation as failed with Runtime.UnhandledPromiseRejection.
The wrapper is the problem when you are debugging. Runtime.UnhandledPromiseRejection describes the handling, not the fault — it says only that nothing caught the error. The error itself sits nested inside the envelope, under reason, with its own type, message and stack. That is the one worth reading, and it is easy to skim past because the outer message repeats a truncated version of it and the whole envelope arrives as one very long JSON line.
The genuinely hard version of this bug is the one where the rejection is not attributable to the invocation you are looking at. A promise created and abandoned during invocation A can reject milliseconds later, by which time the execution environment has been frozen and thawed for invocation B — and it is invocation B that fails. The stack trace points at code that had nothing to do with the request that died. This is precisely why grouping every line of an invocation together matters more here than almost anywhere else: the rejection's stack, the last thing invocation A logged, and the failure recorded against invocation B are three facts that only make sense next to each other.
The prevention is unglamorous and works: every promise is awaited or has a .catch(). The common leaks are the ones that feel harmless at the time — a metric publish nobody waits for, an async callback handed to forEach, a cache warm started at module scope. None of them look like error handling gaps, and all of them are.
CloudWatch’s Errors metric: The error escaped your handler, so Lambda reports the invocation as failed and CloudWatch’s Errors metric counts it. LogStitch classified the invocation above as uncaught.
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
6 raw lines, in the order CloudWatch delivered them.
LogStitch After
The same lines, grouped into the invocation they belong to.
Error8f2c4a71-3e09-4b62-a5d8-0c7e1b9f3a44
- 10:14:07.114PLAT
START RequestId: 8f2c4a71-3e09-4b62-a5d8-0c7e1b9f3a44 Version: $LATEST
- 10:14:07.118INFO
INFO Order accepted order_id=ord_71c4
- 10:14:07.166INFO
INFO Persisted to orders table
- 10:14:07.244ERROR
ERROR Unhandled Promise Rejection {"errorType":"Runtime.UnhandledPromiseRejection","errorMessage":"TypeError: Cannot read properties of undefined (reading 'endpoint')","reason":{"errorType":"TypeError","errorMessage":"Cannot read properties of undefined (reading 'endpoint')","stack":["TypeError: Cannot read properties of undefined (reading 'endpoint')"," at publishMetric (/var/task/src/metrics.js:18:31)"," at handler (/var/task/src/index.js:44:3)"]},"promise":{},"stack":["Runtime.UnhandledPromiseRejection: TypeError: Cannot read properties of undefined (reading 'endpoint')"]} - 10:14:07.288PLAT
END RequestId: 8f2c4a71-3e09-4b62-a5d8-0c7e1b9f3a44
- 10:14:07.288PLAT
REPORT RequestId: 8f2c4a71-3e09-4b62-a5d8-0c7e1b9f3a44 Duration: 174.22 ms Billed Duration: 175 ms Memory Size: 512 MB Max Memory Used: 104 MB Status: error Error Type: Runtime.UnhandledPromiseRejection
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
The runtime writes an Unhandled Promise Rejection envelope as a single JSON line containing errorType, errorMessage and a stack or reason array. Read the inner error inside it: the outer Runtime.UnhandledPromiseRejection tells you only that nothing caught it, while the inner one names what actually went wrong.
The most useful thing to check is where the stack points. A stack frame inside your handler is the easy case. A stack that contains only runtime frames, or the phrase "the promise rejected with the reason", means the rejection came from a promise created outside the handler's await chain — a fire-and-forget call — and the invocation that fails may not be the one that started it.
Causes, most likely first
An async call was made without await and without a catch
Look for calls that return promises and are neither awaited nor given a .catch() — a logging call, a metrics publish, a cache write, anything treated as fire-and-forget. These reject long after the line that started them, which is why the stack trace so often points somewhere that looks unrelated to the request.
A promise inside a callback that cannot receive it
Check for async functions passed to APIs that do not await their return value — forEach, event emitter listeners, setTimeout. The callback returns a promise into a caller that ignores it, so a rejection has nowhere to go.
A rejection in a Promise.all branch after another branch already rejected
Look for Promise.all in the code path. It settles on the first rejection while the other promises keep running; a second one rejecting afterwards has no remaining handler. Promise.allSettled shows this immediately by not rejecting at all.
A rejection during initialisation, outside any handler
Check whether the error appears near INIT_START rather than between START and END. Top-level async work — warming a connection pool, prefetching configuration — rejects with no invocation to attribute it to, and can fail a request that had not started when the promise was created.
Fixes
Await everything, or explicitly handle what you do not
The rule that removes this whole class of bug: every promise is either awaited or has a .catch(). If a call is genuinely fire-and-forget, say so in code, so the rejection is logged rather than fatal.
js// Rejects into nothing — fails a later invocation, with a confusing stack.
// publishMetric(name, value);
// Deliberately fire-and-forget, and says so.
void publishMetric(name, value).catch((error) => {
console.warn("metric publish failed", { name, error: error.message });
});
Do not pass async functions to callbacks that ignore them
forEach discards the promise its callback returns, so a rejection inside it is unhandled by construction. Use a for…of loop when the work must be sequential, or Promise.all over a map when it can be parallel — both keep the promises in the await chain.
js// The promise returned by the callback is thrown away.
// records.forEach(async (record) => { await save(record); });
// Sequential, and every rejection propagates to the handler.
for (const record of records) {
await save(record);
}
// Or parallel, still awaited.
await Promise.all(records.map((record) => save(record)));
Use allSettled when partial failure is acceptable
When several independent operations run together and one failing should not abandon the rest, Promise.allSettled waits for all of them and reports each outcome, so nothing rejects into a void.
jsconst results = await Promise.allSettled(
targets.map((target) => notify(target))
);
const failed = results.filter((result) => result.status === "rejected");
if (failed.length > 0) {
console.error("some notifications failed", {
failed: failed.length,
total: results.length
});
}
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
- AWS Lambda Developer Guide — Error handling in Node.js Lambda functions
- Node.js — process.on('unhandledRejection')
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.