Node.js Serverless on AWS Lambda · Lambda Fundamentals for Node.js
Async Handlers and Unfinished Work
Why a handler that 'works locally' silently drops writes on Lambda, and how to make every piece of work finish before the response.
Scenario
Example app: an order-confirmation function receives a batch of orders, writes an audit record for each to a database and returns { ok: true }. It runs a few hundred times a minute behind an API.
What you see in production
Auditors find missing audit records for roughly 1 in 20 orders. The function never reports an error. Some audit writes appear with timestamps minutes after the order, attached to a completely different request in the logs. Locally, with node and a test script, every record is written.
The naive code
The handler starts the writes but does not wait for them:
export const handler = async (event) => {
event.orders.forEach(async (order) => {
await db.put({ table: "audit", item: { orderId: order.id, at: Date.now() } });
});
return { ok: true }; // returns while the writes are still pending
};
Reproduce it
This simulation models Lambda's behaviour: when the handler's promise resolves, the environment is frozen. Pending work either never runs or resumes during a later invocation:
const written = [];
const pending = [];
const db = { put: (item) => new Promise((resolve) => pending.push(() => { written.push(item); resolve(); })) };
async function naiveHandler(event) {
event.orders.forEach(async (order) => { await db.put({ orderId: order.id }); });
return { ok: true };
}
async function invoke(handler, event) {
const result = await handler(event);
const unfinished = pending.length; // Lambda freezes here: pending work stops
return { result, unfinished };
}
const r1 = await invoke(naiveHandler, { orders: [{ id: "o-1" }, { id: "o-2" }, { id: "o-3" }] });
console.log("invocation 1:", r1.result, "writes left unfinished:", r1.unfinished);
console.log("written after invocation 1:", written.length);
// The environment thaws for the next request; old work may run now, attributed to the wrong request.
pending.splice(0).forEach((run) => run());
console.log("written during invocation 2:", written.map((w) => w.orderId));
Output
invocation 1: { ok: true } writes left unfinished: 3
written after invocation 1: 0
written during invocation 2: [ 'o-1', 'o-2', 'o-3' ]Root cause
Lambda considers an invocation finished when the async handler's promise settles (or the callback is called). It then freezes the execution environment. forEach ignores the promises returned by an async callback, so the handler resolves immediately. Any timers, sockets and promises still pending are suspended: they may never complete (the environment is recycled), or they continue during the next invocation, where their errors and logs belong to the wrong request. Locally, Node keeps the process alive until the event loop is empty, which hides the bug.
The fix
Await everything the response depends on, and decide deliberately how to handle partial failure:
export const handler = async (event) => {
const results = await Promise.allSettled(
event.orders.map((order) => db.put({ table: "audit", item: { orderId: order.id, at: Date.now() } })),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed) throw new Error(`${failed} audit writes failed`); // let the caller or the retry policy handle it
return { ok: true, written: results.length };
};
const written = [];
const db = { put: async (item) => { await Promise.resolve(); written.push(item); } };
async function handler(event) {
const results = await Promise.allSettled(event.orders.map((o) => db.put({ orderId: o.id })));
return { ok: true, written: results.filter((r) => r.status === "fulfilled").length };
}
console.log(await handler({ orders: [{ id: "o-1" }, { id: "o-2" }, { id: "o-3" }] }));
console.log("written:", written.map((w) => w.orderId));
Output
{ ok: true, written: 3 }
written: [ 'o-1', 'o-2', 'o-3' ]Use an async handler (callback-style handlers are legacy, and newer Node.js runtimes drop them), and never mix async with the callback argument.
Trade-offs
Promise.allfails fast on the first error;Promise.allSettledlets you report exactly which items failed. For large batches, limit concurrency so you do not open hundreds of connections at once.- If work really can happen later, hand it off durably (SQS, EventBridge) instead of "fire and forget" inside the function.
- Waiting for everything adds to the response time, which is the honest cost of the work.
Detect it in production
- Log lines whose request ID does not match the invocation that started them, and errors logged after
END RequestId. - Lint rules:
@typescript-eslint/no-floating-promisesandno-misused-promisescatch most un-awaited promises at build time. - Reconciliation jobs that compare expected and written records.
Key points
- Lambda freezes the environment when the handler's promise settles.
forEachwith an async callback does not wait; usePromise.all/allSettledorfor...ofwithawait.- Use async handlers; never rely on background work inside the function.
- Lint for floating promises.
Further reading: AWS Lambda: define a Node.js handler · Lambda execution environment lifecycle
Practice questions
A handler returns before some database writes finish. What happens to those writes on Lambda?
Answer
When the handler's promise settles, Lambda freezes the execution environment. Pending promises are suspended: they may never complete if the environment is recycled, or they may resume during a later invocation, where their logs and errors are attributed to the wrong request.
Why does `array.forEach(async ...)` cause bugs in handlers, and what should you use instead?
Answer
forEach ignores the promises returned by the callback, so nothing waits for them. Use await Promise.all(array.map(...)), Promise.allSettled when you need per-item results, or a for...of loop with await when order or limited concurrency matters.
How can you catch un-awaited promises before they reach production?
Answer
Enable lint rules such as typescript-eslint's no-floating-promises and no-misused-promises, which flag promises that are neither awaited nor handled, and async callbacks passed where a void-returning function is expected.
Open in the app to mark lessons complete, take notes and track your progress.