JavaScript Basics: Read and Update Byte Ranges with FileHandle
Reading a whole file is convenient until you need only a header, a fixed-width record, or a small update at a known offset. A FileHandle keeps one file open while you perform those operations. The important details are byte counts, the current file position, and closing the handle after a failure.
These three independent examples were executed on Linux with Node.js v24.19.0. Use Node.js 24 for this tutorial; only that installed version was tested. They use built-in modules, assertions, and temporary files, with no package installation or network requests. The output below is the observed output.
Prepare a scratch directory
You need Bash, Node.js on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep this terminal open. These commands stop the shell if setup fails; do not save the example files unless both directory commands succeed. Save each named source file as UTF-8 in the new directory. The .mjs extension selects an ES module.
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1Each script creates its own fresh data directory and removes only its named data file. An abrupt process termination can leave that directory behind; inspect leftovers before removing them.
1. Read an exact range and detect a short file
Save this as read-exact-file-range.mjs:
import assert from 'node:assert/strict';
import { mkdtemp, open, writeFile, rm, rmdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
async function readExactlyAt(handle, length, position) {
const result = Buffer.alloc(length);
let filled = 0;
while (filled < length) {
const requested = Math.min(3, length - filled);
const { bytesRead } = await handle.read(
result, filled, requested, position + filled,
);
if (bytesRead === 0) throw new Error('Unexpected end of file');
filled += bytesRead;
}
return result;
}
const directory = await mkdtemp(join(tmpdir(), 'file-range-example-'));
const filename = join(directory, 'records.txt');
let handle;
try {
await writeFile(filename, 'HEADER|alpha|omega', { flag: 'wx' });
handle = await open(filename, 'r');
const header = await readExactlyAt(handle, 6, 0);
const record = await readExactlyAt(handle, 5, 7);
assert.equal(header.toString('utf8'), 'HEADER');
assert.equal(record.toString('utf8'), 'alpha');
await assert.rejects(readExactlyAt(handle, 6, 13), /Unexpected end of file/);
assert.equal((await readExactlyAt(handle, 0, 0)).length, 0);
console.log(`header: ${header.toString('utf8')}`);
console.log(`record: ${record.toString('utf8')}`);
console.log('truncated range: rejected; empty range: accepted');
} finally {
await handle?.close();
await rm(filename, { force: true });
await rmdir(directory);
}
console.log('temporary data removed');Run it:
node read-exact-file-range.mjsOutput:
header: HEADER
record: alpha
truncated range: rejected; empty range: accepted
temporary data removedThe buffer offset says where to put bytes in memory; the file position says where to read them from. The loop deliberately requests at most three bytes, so even this small fixture exercises multiple reads. It advances by the actual bytesRead value rather than assuming that one read fills the request. A zero-byte result before the requested range is complete means the fixture ended too soon.
The helper accepts trusted nonnegative integer offsets and small lengths supplied by this script. Validate and cap externally supplied lengths before allocating a buffer. These are byte offsets, not character indexes: the ASCII fixture keeps those concepts visibly aligned.
2. Separate explicit positions from the shared cursor
Save this as compare-file-positions.mjs:
import assert from 'node:assert/strict';
import { mkdtemp, open, writeFile, rm, rmdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
async function readPair(handle, position) {
const buffer = Buffer.alloc(2);
const { bytesRead } = await handle.read(buffer, 0, 2, position);
assert.equal(bytesRead, 2);
return buffer.toString('utf8');
}
const directory = await mkdtemp(join(tmpdir(), 'file-cursor-example-'));
const filename = join(directory, 'letters.txt');
let handle;
try {
await writeFile(filename, 'abcdefgh', { flag: 'wx' });
handle = await open(filename, 'r');
const first = await readPair(handle, null);
const last = await readPair(handle, 6);
const next = await readPair(handle, null);
const remaining = await handle.readFile('utf8');
assert.deepEqual([first, last, next, remaining], ['ab', 'gh', 'cd', 'efgh']);
assert.equal(await handle.readFile('utf8'), '');
console.log(`cursor first: ${first}`);
console.log(`position 6: ${last}`);
console.log(`cursor next: ${next}`);
console.log(`readFile remainder: ${remaining}`);
console.log('second readFile: empty');
} finally {
await handle?.close();
await rm(filename, { force: true });
await rmdir(directory);
}Run it:
node compare-file-positions.mjsOutput:
cursor first: ab
position 6: gh
cursor next: cd
readFile remainder: efgh
second readFile: emptyPassing null consumes from the handle’s current position. A nonnegative explicit position leaves that cursor alone. The final readFile starts at the current cursor, so it returns the remainder, not necessarily the entire file. These behaviors are documented for FileHandle reads and readFile on a handle.
Here each two-byte read is checked rather than retried because the example is specifically inspecting cursor behavior on a tiny local fixture. Use the looping approach from the first example when your application requires a complete range. Keep operations sharing a cursor sequential.
3. Patch a fixed-width field without replacing the file
Save this as patch-fixed-file-field.mjs:
import assert from 'node:assert/strict';
import { mkdtemp, open, readFile, rm, rmdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const directory = await mkdtemp(join(tmpdir(), 'file-patch-example-'));
const filename = join(directory, 'counter.txt');
let handle;
try {
handle = await open(filename, 'wx+');
await handle.writeFile('count=0000\n', 'utf8');
await assert.rejects(open(filename, 'wx'), { code: 'EEXIST' });
const patch = Buffer.from('0042', 'utf8');
let written = 0;
while (written < patch.length) {
const { bytesWritten } = await handle.write(
patch, written, patch.length - written, 6 + written,
);
if (bytesWritten === 0) throw new Error('Write made no progress');
written += bytesWritten;
}
const contents = await readFile(filename, 'utf8');
assert.equal(contents, 'count=0042\n');
assert.equal((await handle.stat()).size, 11);
console.log(contents.trimEnd());
console.log(`bytes patched: ${written}; file size: 11`);
console.log('second exclusive create: rejected');
} finally {
await handle?.close();
await rm(filename, { force: true });
await rmdir(directory);
}Run it:
node patch-fixed-file-field.mjsOutput:
count=0042
bytes patched: 4; file size: 11
second exclusive create: rejectedThe wx+ flag creates a new file for reading and writing, refusing an existing path. The patch replaces exactly four bytes; it does not insert text or shift later bytes. The full-file assertion verifies that the prefix and newline survived. The loop handles a partial write by advancing both the buffer offset and the file position.
Practical limits
Do not use append mode for positional updates: on Linux, positional writes to an append-open file append instead. Await writes and close handles explicitly. These small in-place writes are not a transaction and are not a crash-safe update protocol. A second process can change the same file, and a successful write is not by itself a promise of durable storage. The examples deliberately avoid concurrent access and modify only their own temporary fixtures. See the FileHandle write notes.
Clean up
In the same terminal, remove only the three source files, return to your starting directory, and remove the empty scratch directory:
rm -- read-exact-file-range.mjs compare-file-positions.mjs patch-fixed-file-field.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.