What it means
A process may have only so many open file descriptors at once, and on Linux a descriptor is not only a file. Sockets are descriptors. Pipes are descriptors. Every open HTTP connection, every database socket, every stream you have not finished reading occupies one. EMFILE means the process asked for another and there were none left.
On a long-running server this is a slow-burning classic. On Lambda it behaves differently, and the difference is the execution environment. Lambda keeps the process alive between invocations so that warm starts are fast, which means descriptors leaked during one invocation are still leaked during the next. A handler that opens one file and forgets to close it on an error path leaks one descriptor per failed request, forever, on that container.
The resulting pattern is genuinely confusing when read one invocation at a time. Cold starts always work — a fresh process has a fresh table. Early invocations work. Then one container starts failing every request while its siblings, which happen to have served fewer or luckier requests, keep succeeding. The error rate is neither zero nor total, does not correlate with input, and appears to wander between hosts. Per-execution-environment it is a clean sawtooth: climb, cliff, recycle, climb again.
The two shapes worth telling apart are the leak and the spike. A leak accumulates across invocations, so the failure lands on a container that has been alive a while and the failing operation may be entirely innocent — it was simply the one that asked last. A spike is a single invocation opening too many at once, usually Promise.all over a large array, and it fails identically on a cold start. The first is fixed by closing things in finally and hoisting clients to module scope; the second by bounding parallelism. Knowing which invocation of a container's life you are looking at is what separates them.
CloudWatch’s Errors metric: Whether CloudWatch’s Errors metric counts this depends on whether the error escaped your handler, which the log line alone does not settle. Counted only when the error escapes the handler. LogStitch classified the example below from its log level rather than an extracted error type, so it reports the failure as caught.
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.
Error4e91c07b-3a62-4d18-b750-2c8f6a1e9d43
- 18:03:21.114PLAT
START RequestId: 4e91c07b-3a62-4d18-b750-2c8f6a1e9d43 Version: $LATEST
- 18:03:21.118INFO
INFO Merging shard files count=64
- 18:03:21.402ERROR
ERROR Error: EMFILE: too many open files, open '/tmp/chunk-8841.bin' at Object.open (node:internal/fs/promises:637:25) at mergeShards (/var/task/src/merge.js:44:28)3 lines - 18:03:21.588PLAT
END RequestId: 4e91c07b-3a62-4d18-b750-2c8f6a1e9d43
- 18:03:21.588PLAT
REPORT RequestId: 4e91c07b-3a62-4d18-b750-2c8f6a1e9d43 Duration: 474.11 ms Billed Duration: 475 ms Memory Size: 1024 MB Max Memory Used: 208 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 at where in the container's life it happens. EMFILE on a cold start means one invocation genuinely opened too many at once. EMFILE after many successful invocations on the same execution environment means a leak — each request opened something and never closed it.
The sawtooth is the signature, and it is only visible across invocations. A run of successes, then a cliff, then a fresh execution environment starting the climb again. Looking at the failing invocation alone explains nothing, because the invocation that exhausted the descriptors is rarely the one that leaked them.
Causes, most likely first
File handles are not closed on the error path
Check whether every open has a matching close that runs even when something throws. A handle closed at the end of a happy path leaks on every failure, which means the leak rate tracks your error rate and appears during exactly the incidents you least want a second problem.
HTTP agents or connection pools are created per invocation
Look for clients, agents or pools constructed inside the handler. Each one opens its own sockets, and sockets are descriptors. Creating them per request rather than at module scope multiplies descriptor use by the number of invocations the container serves.
Streams are abandoned rather than consumed or destroyed
Look for streams that are opened and then dropped — an S3 response body that is never read because an early return happened first, or a read stream abandoned mid-iteration. The underlying socket stays open until it is consumed or explicitly destroyed.
One invocation legitimately opens a very large number at once
Check for unbounded parallelism. Promise.all over a large array of file or network operations opens all of them simultaneously, so the peak is the size of the array rather than anything you chose.
Fixes
Close in a finally block, so the error path closes too
The failure path is the one that leaks, and it is the one least often tested. finally is the only placement that runs on every exit route.
jsimport { open } from "node:fs/promises";
const handle = await open(path, "r");
try {
return await read(handle);
} finally {
await handle.close();
}
Create clients and agents once, at module scope
Module-scope construction means one pool per execution environment rather than one per invocation. This is the same practice that makes warm starts fast, and it bounds descriptor use at the same time.
jsimport { Agent } from "node:https";
// One agent per execution environment, with a bounded socket count.
const agent = new Agent({
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 30_000
});
export const handler = async (event) => fetch(url, { agent });
Bound parallelism instead of opening everything at once
Promise.all over a large array opens every operation simultaneously. Working in chunks keeps the peak fixed regardless of how many items arrive.
jsconst CONCURRENCY = 10;
for (let i = 0; i < items.length; i += CONCURRENCY) {
await Promise.all(items.slice(i, i + CONCURRENCY).map(process));
}
Consume or destroy every stream you open
An unread response body holds its socket open. Destroying it explicitly on the paths where you decide not to read it releases the descriptor immediately.
jsconst response = await s3.send(new GetObjectCommand(params));
if (!shouldProcess(response.ContentLength)) {
// Not reading it — release the socket rather than leaving it dangling.
response.Body.destroy();
return { skipped: true };
}
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.