File Descriptors: Handles, Kernel Resources, and Close Semantics
A file descriptor is the small process-local handle that lets JavaScript code refer to an open file-like resource without carrying the operating-system object itself around. Node exposes that handle either as a numeric fd from callback and sync APIs, or as a FileHandle from promise APIs. The details that count in real programs are not the number itself, but the state behind it: which flags were used to open the resource, who owns the lifetime, when close happens, and what happens when the process runs out of descriptor slots.
On POSIX systems, the descriptor is an integer in the process descriptor table. On Windows, Node and libuv translate Windows file handles into the fd abstraction used by fs APIs. That translation lets most JavaScript code use the same shape on both platform families, while OS-specific behavior still shows through in places such as permissions, sharing, and path handling.
File Descriptors
fs.open() begins with a path, flags, and an optional mode, but the result is not a path-shaped object. The operating system creates open-file state and returns a process-local token for reaching that state again. Node exposes that token as either a numeric descriptor or a FileHandle. Because the token keeps kernel state alive, code that opens one must also arrange to close it.
Every fd-based API works through the same basic idea. On POSIX systems, the token is a non-negative integer indexing the process's file descriptor table. On Windows, the OS gives libuv a HANDLE, and libuv presents a descriptor-shaped value back to Node. JavaScript mostly sees a value it can pass to reads, writes, stats, syncs, and close.
The token is small, but it points at more state than its size suggests. Calls such as fs.readFile(), fs.createReadStream(), fs.writeFile(), fs.open(), FileHandle methods, sockets, pipes, standard input, and standard output all meet the operating system through open resources recorded in kernel state. Other path-oriented operations, including stat, access, rename, and unlink, can complete without leaving a persistent open descriptor in JavaScript.
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fd-example-'));
const file = path.join(dir, 'example.txt');
const fd = fs.openSync(file, 'w');
console.log(fd);
fs.closeSync(fd);
fs.rmSync(dir, { recursive: true, force: true });The printed value might be 17, 22, or something nearby. Node opens a few descriptors during startup, so user code rarely gets 3 in a real process. The number has meaning only inside that process. Another process can also have fd 17, pointing at a different file, socket, pipe, or terminal. The important property is the process-local reference, not the particular integer.
The Descriptor Table
A normal process does not begin with an empty descriptor table. Descriptors 0, 1, and 2 are normally already reserved for standard input, standard output, and standard error. The parent process sets those up before Node starts running your program.
In a shell, fd 0 usually points at terminal input, fd 1 at terminal output, and fd 2 at the same terminal through the error stream. In a service manager, container runtime, or pipeline, those same numbers may point at pipes, sockets, log collectors, or files. The numbers stay conventional; the targets depend on how the process was launched.
At the JavaScript layer, Node exposes stdio as streams. Underneath, the writes still target descriptors. console.log() eventually writes bytes to fd 1. process.stderr.write() writes bytes to fd 2.
After those first three descriptors, POSIX systems assign the lowest free descriptor number for each open operation. Close one, and the slot becomes reusable. With three writable paths already chosen, the reuse is easy to see:
const a = fs.openSync(aPath, 'w');
const b = fs.openSync(bPath, 'w');
fs.closeSync(a);
const c = fs.openSync(cPath, 'w');
console.log({ a, b, c });
fs.closeSync(b);
fs.closeSync(c);c often equals a, because closing a frees that descriptor-table slot. The exact values depend on descriptors Node already holds, but the reuse behavior comes from the OS. That reuse is harmless when ownership is clear, and dangerous when stale fd integers escape past their close.
On POSIX systems, the descriptor table entry is only the first layer. It points at an open file description in kernel memory. That open file description tracks the current offset, access mode, append behavior, status flags, and a reference to the underlying file object. The file object eventually points at an inode on POSIX filesystems. If the descriptor represents a socket or pipe, the target kernel object has socket or pipe state instead.
The descriptor table is uniform; the target object determines which operations are legal. That is why the same table can hold regular files, TCP sockets, Unix domain sockets, pipes, and special files such as /dev/null on Unix-family systems. The table only records "fd N points at this open kernel object." The object itself defines what can be read, written, polled, synced, or closed.

Figure 1 — A descriptor-table entry is only the process-local entry point. Open resource state behind it carries offsets, flags, and references to the underlying file, socket, or pipe.
Opening a File
fs.open() is the split where a JavaScript path turns into an operating-system resource and then into a descriptor. Given a path that already points at a file, the callback receives the descriptor:
fs.open(file, 'r', (err, fd) => {
if (err) return handleError(err);
console.log(fd);
fs.close(fd, (closeErr) => {
if (closeErr) handleError(closeErr);
});
});The callback receives an fd only after the open operation has finished in native code. Node converted your path and flags, called into the C++ binding layer, created a libuv filesystem request, and handed that request to libuv.
The kernel side has a fixed shape. It resolves the path component by component, checks permissions, applies flags, creates or finds the file object, allocates an open file description, installs a pointer to that description into the process descriptor table, and returns the descriptor number.
On Linux and macOS, the native call is open() or a close relative. On Windows, libuv calls CreateFileW() and maps the returned HANDLE into its file abstraction. Node's fs API covers most of that split, but Windows behavior can still show up around sharing modes, path length, and permissions.
A descriptor remains process-scoped after open returns. On POSIX systems, two processes opening /tmp/data.txt get separate descriptor table entries and separate open file descriptions. Each open call has its own file offset. Read 100 bytes through one descriptor, and the offset for that open description advances by 100. A separate open call keeps its own offset.
Duplicated descriptors behave differently on POSIX systems because they point at the same open file description. dup(), fork(), and descriptor inheritance can create multiple descriptor-table entries sharing that state. If one descriptor reads 100 bytes, the next read through the other starts from the new position. Node keeps most application code away from raw fork() inheritance, but the OS behavior explains odd results when descriptors are passed between processes.
Flags Decide the Open State
The second argument to fs.open() controls access mode and creation behavior. String flags are the readable surface; underneath, Node maps them to OS-level bit flags.
| Flag | Open behavior |
|---|---|
'r' | Opens for reading. The file must already exist; a missing path gives ENOENT. |
'r+' | Opens for reading and writing. The file must already exist, and the offset starts at byte 0. |
'w' | Opens for writing, creates the file when needed, and truncates existing content during open. |
'w+' | Opens for reading and writing with the same create-and-truncate behavior. |
'a' | Opens for appending, creates the file when needed, and makes each write land at the current end of the file. |
'a+' | Opens for reading and appending. Reads can use positions; writes still append. |
Append mode is the flag in that table most likely to surprise people. On Linux, the kernel ignores the write position and appends anyway. If you need to overwrite bytes at a known offset, do not open the file with append flags.
Exclusive creation is another place where the flag is more useful than a separate check. 'wx' opens for writing with exclusive creation. If the path already exists, the open fails with EEXIST. On local filesystems the check and creation happen inside the kernel operation, so two processes competing for the same path get one winner. Node documents the usual O_EXCL caveat: exclusive creation may not work reliably on network filesystems.
let fd;
try {
fd = fs.openSync(lockPath, 'wx', 0o600);
fs.writeSync(fd, `${process.pid}\n`);
} catch (err) {
if (err.code === 'EEXIST') console.error('lock already exists');
else throw err;
} finally {
if (fd !== undefined) fs.closeSync(fd);
}That pattern is common for one-time creation. It is only a small part of a real lock-file protocol: production lock files also need stale-lock handling, target-filesystem testing, and clear behavior when a process dies after creating the file. The finally handles the fd. If writeSync() throws after the open succeeds, the descriptor still closes.
The important part is that the existence decision happens inside open. fs.existsSync() followed by fs.openSync(..., 'w') has a race between the two calls. O_EXCL folds the check and creation into one kernel operation.
Numeric flags exist too:
const flags =
fs.constants.O_WRONLY |
fs.constants.O_CREAT |
fs.constants.O_TRUNC;
const fd = fs.openSync(file, flags);
fs.closeSync(fd);String flags read better in application code. Numeric flags show up when you need a specific platform flag, when porting C logic, or when a native addon interface already speaks in flag bits.
Creation Mode and umask
The third fs.open() argument is used only when the open call creates a file. It sets the requested permission bits before the operating system applies the process mask.
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fd-mode-'));
const file = path.join(dir, 'secret.txt');
const fd = fs.openSync(file, 'w', 0o600);
fs.closeSync(fd);
fs.rmSync(dir, { recursive: true, force: true });0o600 gives the owner read and write permission. Group and others get no permission bits. The three octal digits encode owner, group, and others; inside each digit, read is 4, write is 2, and execute is 1.
Node's default creation mode is 0o666, which means read and write for owner, group, and others before the process mask is applied. On POSIX and Unix-family systems, the process umask clears bits from that requested mode. A common 0o022 mask turns 0o666 into 0o644: owner read/write, everyone else read-only.
That masking step causes real deployment bugs. Passing 0o666 asks for broad permissions, and the OS mask narrows the result. The service manager, shell, container image, or init system that starts production may set a different mask than your development shell.
Windows uses ACLs for actual permission decisions. Node can expose and manipulate a small POSIX-style mode surface there, but those bits are not a complete Windows access-control model.
Close Is Part of the Operation
A descriptor's lifecycle is not finished when the last read or write returns. The operation is open, use, and close on every path. Each part has state behind it. open allocates descriptor-table state and kernel open-file state. read, write, fstat, fsync, and related calls use that state. close releases the descriptor slot and decrements the kernel reference count for the open file description.
fs.open(file, 'r', (err, fd) => {
if (err) return handleError(err);
fs.read(fd, Buffer.alloc(64), 0, 64, 0, (readErr, bytesRead) => {
fs.close(fd, (closeErr) => {
if (readErr) return handleError(readErr);
if (closeErr) return handleError(closeErr);
console.log(`read ${bytesRead} bytes`);
});
});
});The read call uses an explicit position of 0, so it reads from the beginning and leaves the descriptor's current offset alone. The close happens inside the callback because the descriptor must remain open until the async operation completes. The nested shape is the point: raw fd code needs an explicit close path, including failure paths. Production code may preserve both read and close errors with an AggregateError, but it still has to reach fs.close(). Move fs.close() above fs.read(), and the read races against descriptor reuse or fails with EBADF.
close() also has a split worth keeping separate from durability. It releases the descriptor. Written bytes may still sit in dirty pages in the page cache. Use fs.fsync() or FileHandle.sync() when you need crash durability, then close. Check close errors too: on POSIX systems, delayed write failures such as quota exhaustion, ENOSPC, or some NFS writeback errors may be reported by close().
The reuse rule from the descriptor table comes back here. Suppose code closes fd 18, then another part of the process opens a socket and gets fd 18. A stale async callback that still holds the old integer can now operate on the new socket. Node's higher-level APIs reduce that risk by owning descriptors internally, but raw fd code leaves the lifecycle in your code.
Leaks Become EMFILE
A leaked descriptor stays allocated until the process exits or some cleanup path eventually closes the handle.
One leak barely moves the needle. One leak per request takes the service down. The OS enforces a per-process descriptor limit, and Node shares that budget across files, sockets, pipes, stdio, internal libuv descriptors, and active TCP connections. Unix-family systems can also fail opens with ENFILE when the system-wide open-file table is exhausted.
Check the shell limit:
ulimit -nDefaults vary widely, so check the limit in the same shell, service manager, or container that starts the process.
When the process runs out, open operations fail with EMFILE. When the system runs out, the corresponding failure is ENFILE.
To force the failure in a disposable shell, lower the soft limit before running a script that repeatedly opens the same existing file:
ulimit -n 64
node emfile-demo.cjsconst fds = [];
try {
for (let i = 0; i < 2000; i++) {
fds.push(fs.openSync(file, 'r'));
}
} catch (err) {
console.error(err.code);
} finally {
for (const fd of fds) fs.closeSync(fd);
}Every successful open consumes a slot until the finally block closes it. Under a low test limit such as 64, this snippet reaches EMFILE quickly once file points at an existing file. On a host with a high soft limit, the loop may finish without failure. If file does not exist, the first failure would be ENOENT, which proves nothing about descriptor pressure.

Figure 2 — Descriptor leaks are cumulative. Once the finite table is full, the next legitimate open can fail even though that request did nothing wrong.
Real leaks often hide in error paths because the success path looks correct.
The bug sketch is small:
fs.open(file, 'r', (err, fd) => {
if (err) return handleError(err);
doWork(fd, (workErr) => {
if (workErr) return handleError(workErr);
fs.close(fd, (closeErr) => {
if (closeErr) handleError(closeErr);
});
});
});If doWork() reports an error, the descriptor stays open. The error handler exits before the close call. In callback code, success and failure both need to pass through cleanup.
The corrected shape closes first, then reports the work failure:
fs.open(file, 'r', (err, fd) => {
if (err) return handleError(err);
doWork(fd, (workErr) => {
fs.close(fd, (closeErr) => {
if (workErr) return handleError(workErr);
if (closeErr) return handleError(closeErr);
finish();
});
});
});Production code may preserve both errors with an AggregateError. The important part is simpler than the error-shaping policy: once an fd exists, every callback path has to reach fs.close().
On Linux, /proc/self/fd gives the current process's open descriptor list:
const fs = require('node:fs');
if (process.platform === 'linux') {
const count = fs.readdirSync('/proc/self/fd').length;
console.log('open descriptors:', count);
}Treat that number as a gauge, not an exact invariant. Reading the directory can involve transient descriptors, and the process may open or close other resources while you sample it.
For an external view, use lsof:
PID=12345
lsof -p "$PID"When lsof is installed, that output shows descriptors, target paths, sockets, pipes, and deleted files still held open. It may also print host or container warnings while collecting metadata. A count that grows over time under steady traffic usually means a leak. Flat counts under load mean the process is opening and closing at a stable rate.
Raising the limit increases capacity:
ulimit -n 65536That change helps a server with many legitimate concurrent sockets. Leaking code still fails after more requests. Shell ulimit changes apply to that shell session and its children; systemd, Docker, Kubernetes, launchd, and other service managers have their own limit settings. Fix the close path first, then size the limit for real concurrency in the environment that actually starts the process.
FileHandle Is the Better Default
Raw fd integers separate the resource from the methods that operate on it, which makes ownership easy to lose. open() from node:fs/promises wraps the descriptor in a FileHandle so the operations and the resource travel together. The next examples open the current ES module file, so they do not need a separate setup file.
import { open } from 'node:fs/promises';
const fh = await open(new URL(import.meta.url), 'r');
console.log(fh.fd);
await fh.close();The .fd property exposes the underlying descriptor, but most code should stay on the object. The next fs.promises chapter covers the method surface in detail; here the point is ownership. A handle gives the descriptor a clear place to live until it is closed.
Use try and finally when a handle spans more than one operation.
import { open } from 'node:fs/promises';
const fh = await open(new URL(import.meta.url), 'r');
try {
const buf = Buffer.alloc(256);
const result = await fh.read(buf, 0, 256, 0);
console.log(result.bytesRead);
} finally {
await fh.close();
}finally runs on success and failure. The descriptor closes when parsing throws, when a read fails, or when later code returns early. That structure is the main reason FileHandle is the default choice for new code.
Current releases also support explicit resource management. In Node.js v24.2 and newer, FileHandle[Symbol.asyncDispose] is no longer experimental, so await using can close a handle at scope exit when your runtime and tooling accept the syntax. Older Node lines may expose the API as experimental or lack parser/tooling support for using.
import { open } from 'node:fs/promises';
{
await using fh = await open(new URL(import.meta.url), 'r');
const text = await fh.readFile('utf8');
console.log(text.length);
}await open() waits for the open operation. await using registers async disposal for the binding. ESM keeps top-level await using in the module context where readers usually see it. When execution leaves the block, Node calls the handle's async disposer, which closes the descriptor.
Explicit close is still mandatory. Node may try to close an unclosed FileHandle during garbage collection and warn, but the docs explicitly say not to rely on that behavior because it can change. Either way, GC timing is unrelated to descriptor pressure, traffic bursts, or your fd limit, so it is not a usable cleanup path.
Raw descriptors still have a place. Legacy callback code uses them. Native addons may expect them. Some low-level APIs still speak in integers. For application code written with async and await, FileHandle is the clearer API.
The libuv Path
File I/O APIs expose async callbacks and promises to JavaScript. For regular files, though, the common Node path still uses blocking operating-system calls; the blocking work is moved away from the main JavaScript thread.
libuv exposes filesystem work through uv_fs_* functions: uv_fs_open(), uv_fs_read(), uv_fs_write(), uv_fs_close(), and the rest. Each async call uses a uv_fs_t request. Node's C++ binding fills that request with the path, fd, flags, mode, buffers, offsets, and callback state needed by the operation.
That design comes from the portability problem around regular files. Linux has io_uring, older Linux AIO exists with constraints, macOS readiness APIs fit sockets better than regular files, and Windows has overlapped I/O with different semantics. In Node v24, the documented path is specific: callback and promise-based filesystem APIs use libuv's threadpool, except for file watchers. Sync APIs are different because they block the calling JavaScript thread. The default pool has four threads and is controlled by UV_THREADPOOL_SIZE.
For a call such as fs.open('/tmp/x', 'r', cb), the sequence is concrete. Node validates arguments in JavaScript. The C++ binding receives the call and creates an FS request object. libuv queues work to the thread pool. A worker thread calls the platform open primitive. The kernel resolves the path, checks permissions, allocates open-file state, and returns either an fd-shaped value or an error. The worker stores the result on the uv_fs_t request and posts completion back to the loop. On the main thread, Node reads the request result, builds the JavaScript callback arguments, and calls your callback.

Figure 3 — Async filesystem APIs keep JavaScript moving by queueing blocking filesystem work onto libuv workers, then returning completion to the event loop.
That sequence has two direct consequences. The event loop stays available while the worker thread blocks in the syscall. At the same time, the worker pool can become the bottleneck. Fire 500 concurrent fs.open() calls with the default pool, and at most four worker-pool filesystem tasks can execute at once. The rest wait in libuv's queue. Latency rises.
The pool is shared, so filesystem calls are not the only work waiting there. dns.lookup(), many crypto operations, compression work, and native addons or runtime work queued with uv_queue_work compete with filesystem operations. Increasing UV_THREADPOOL_SIZE can help file-heavy workloads, but it increases native thread count and can add scheduling overhead. Measure the workload. Blindly setting it to a large number often moves the wait from libuv's queue to the kernel scheduler or storage device.
Once the worker reaches open(), the kernel layers described earlier take over. On Linux, the process has a descriptor table. Entries in that table point at open file descriptions. Open file descriptions point at inodes or other kernel objects. Multiple descriptors can point at one open file description after duplication or inheritance. Multiple open file descriptions can point at the same inode after independent opens.
That difference explains offset behavior. Two independent calls to fs.open('/tmp/log', 'r') produce separate open file descriptions. Their offsets move independently. A duplicated descriptor shares the same open file description, so its offset moves with the original. Append mode adds another rule: with O_APPEND, the kernel positions each write at end-of-file as part of the write operation.
close() unwinds those references in the other direction. The descriptor-table slot is freed. The open file description reference count drops. When the last descriptor pointing at that open file description closes, the kernel releases it. If the directory entry has already been removed, POSIX keeps the file data alive until that last close. That is why disk space may stay used after rm when a process still has the file open.
For startup stdio and inherited descriptors, Node spawned children get a controlled view. Node's child_process APIs build the child's standard streams from the stdio option and pass extra descriptors only when you explicitly configure them there. IPC can pass certain network handles later through sendHandle, but that is a separate channel. In normal code, do not assume arbitrary application fds are inherited by child programs.
Sync APIs take a shorter native path. fs.openSync() calls the filesystem operation on the main JavaScript thread and blocks until the OS returns. The call can be cheap when metadata is hot in cache. It can stall the whole process when storage is slow, remote, busy, or waiting on permissions infrastructure. Use sync file calls during startup, CLIs, and scripts where blocking the event loop has zero request traffic cost.
Cross-Platform Cases
Most descriptor code stays portable when you use Node APIs and node:path. The remaining problems usually come from assuming that every filesystem behaves like the one on your development machine.
| Area | What changes |
|---|---|
| Path separators | Use path.join() and path.resolve() instead of hard-coding separators. |
| Case behavior | Linux filesystems are usually case-sensitive. Windows and default macOS filesystems usually treat File.txt and file.txt as the same path, so tests that pass on one platform can create name collisions on another. |
| Permissions | POSIX modes and umask map cleanly on Unix-family systems. Windows ACLs carry the real permission model. |
| File locking | POSIX locks are commonly advisory. Windows sharing modes can prevent other opens. Node core does not expose a portable flock or fcntl-style locking API, so applications that need lock files usually use a package built for that job and test it on the target OS. |
| Path length | Modern Windows APIs can handle long paths with the right prefixes and process settings, while old assumptions around MAX_PATH still surface in tools. POSIX path limits are byte-based and vary by filesystem. |
Production Habits
Prefer APIs that own descriptors internally. readFile, writeFile, appendFile, createReadStream, and createWriteStream open and close for you when you pass a path and keep the default ownership options. Streams can also accept an fd or FileHandle, and autoClose: false leaves ownership with your code. Reach for fs.open() or FileHandle when you need repeated operations against one descriptor, random access, explicit durability calls, or integration with code that already expects an fd.
Once ownership is clear, limit fan-out. Processing 50,000 files does not require 50,000 active descriptors.
async function runLimited(items, concurrency, worker) {
let next = 0;
const runners = Array.from({ length: concurrency }, async () => {
while (next < items.length) await worker(items[next++]);
});
await Promise.all(runners);
}
await runLimited(paths, 50, processFile);That keeps at most 50 file operations active through your code path. If one worker rejects, Promise.all() rejects; runners that are already inside their current await are not canceled by this helper. The exact number belongs to the workload: descriptor limits, storage latency, thread-pool pressure, and the rest of the process all count.
Track open descriptors in production. On Linux, export /proc/self/fd count as a gauge. Watch it beside request rate and active sockets. A rising descriptor count during flat traffic is a leak signal.
Close in finally. Use await using where your toolchain supports it. Keep raw fd integers contained to the smallest scope that needs them.
Descriptors are finite process resources. Node gives you high-level APIs that own most of the lifecycle, but the kernel still accounts for every open file, socket, and pipe. When the count hits the limit, the next open fails. The fixes are concrete: fewer concurrent opens, tighter cleanup, and metrics before EMFILE becomes the first alert.