The File System in Node.js: fs/promises, path, Buffer and File Handling Pitfalls
On a Node.js server, file work is done with node:fs/promises, but the real job is handling encodings correctly, writing files atomically and keeping user-supplied paths inside the root folder. With real output: an 8-character Turkish title taking 11 bytes, Turkish characters breaking at chunk boundaries, the BOM in Excel's CSV, 61 of 62 reads seeing a half-written file with writeFile, lost updates, the startsWith trap, and the Permission Model disabling fsync.
Version note: The examples in this post were run with Node.js 26.13.1 (LTS) and TypeScript 7.0.2.
Guess First
We are adding a Turkish classic to the catalog of Bookstore, the online bookstore used as the shared example across this site's series. We convert the title to UTF-8 bytes and turn the first byte back into a string:
// A Turkish classic: how long is the title?
const title = "Çalıkuşu";
const bytes = Buffer.from(title, "utf8");
console.log(title.length);
console.log(bytes.length);
console.log(bytes.subarray(0, 1).toString("utf8"));
What do the three lines print? Is the length 8 on both lines, and is the last line Ç?
node labs/08/guess.ts
8
11
�
The string has 8 characters but 11 bytes in UTF-8: Ç, ı and ş take two bytes each. The first byte on its own is not a valid character, so the replacement character (U+FFFD) appears instead. Mixing up a string's length with the number of bytes in a file, decoding chunks one by one and assuming an encoding will eventually cause trouble in any system that handles Turkish data.
In this post we look at Node's file system APIs from the server's point of view: which API family to use where, path handling with path, Buffer and encodings, atomic writes, concurrent write races and defending against path traversal. We also add two pieces to Bookstore: the book cover folder and a catalog import from a CSV file.
Three API Families: Sync, Callback and Promise
import { readFileSync, readFile } from "node:fs";
import { readFile as readFileAsync } from "node:fs/promises";
const path = new URL("../../src/db/books.json", import.meta.url);
// 1) Sync: blocks the event loop until the read finishes; fine at startup
const sync = readFileSync(path, "utf8");
console.log(`sync: ${sync.length} chars`);
// 2) Callback: error-first, the oldest API
readFile(path, "utf8", (err, text) => console.log(`callback: ${err ? err.code : text.length + " chars"}`));
// 3) Promise: what we use in request handlers
const text = await readFileAsync(path, "utf8");
console.log(`promise: ${text.length} chars`);
sync: 454 chars
callback: 454 chars
promise: 454 chars
All three do the same job; the difference is what happens while waiting. The sync version holds the main thread until the file has been read. The callback and promise versions hand the work to libuv's thread pool (06) and leave the event loop free. The rule on a server: only node:fs/promises on the request path. The sync APIs are fine for reading a configuration file at startup or in a CLI script. There is no reason to use the callback version in new code (07).
One difference for those coming from .NET: on Linux, C# APIs such as File.ReadAllTextAsync also wait for the file read on a ThreadPool thread, but the ThreadPool grows as needed, so you usually don't notice. Node's pool is a fixed 4 threads by default; many concurrent file operations and crypto share the same pool.
Working with Files and Folders: mkdir, readdir, stat and glob
In Bookstore, book covers will live at uploads/covers/<isbn>.jpg. The listing function in src/storage/coverStorage.ts shows the three basic calls together:
export async function listCovers(): Promise<CoverInfo[]> {
const entries = await readdir(coversRoot, { withFileTypes: true }).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
return [];
}
throw err;
});
const files = entries.filter((e) => e.isFile() && e.name.endsWith(".jpg"));
return Promise.all(
files.map(async (e) => {
const info = await stat(path.join(coversRoot, e.name));
return { isbn: path.basename(e.name, ".jpg"), sizeBytes: info.size, modifiedUtc: info.mtime.toISOString() };
}),
);
}
With withFileTypes: true, readdir returns Dirent objects instead of names, so you don't need a separate stat call for isFile() and isDirectory(). If the folder does not exist you get ENOENT; here we interpret it as "no covers yet" and rethrow every other error. Error codes (ENOENT, EACCES, EEXIST, EPERM) come from the operating system and live in err.code; there are no separate exception types like .NET's FileNotFoundException, so you tell errors apart by this code. To create folders we use mkdir(dir, { recursive: true }): it creates intermediate folders too and does not fail if the folder already exists.
fs.glob, which arrived in Node 22 and became stable in 24, finds files by pattern without an extra package. Let's save some covers and list them both ways:
import { glob } from "node:fs/promises";
import { saveCover, listCovers } from "#storage/coverStorage";
// Smallest possible stand-ins: a JPEG starts with FF D8 FF, a PNG with 89 50 4E 47
const fakeJpeg = (size: number) => Uint8Array.from({ length: size }, (_, i) => [0xff, 0xd8, 0xff, 0xe0][i] ?? 0);
const fakePng = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
for (const [isbn, size] of [["9780441172719", 48_213], ["9780547928227", 35_870]] as const) {
const saved = await saveCover(isbn, fakeJpeg(size));
console.log(`saved ${saved.isbn}: ${saved.sizeBytes} bytes`);
}
for (const [isbn, bytes] of [["9780141439600", fakePng], ["../../src/server", fakeJpeg(10)]] as const) {
try {
await saveCover(isbn, bytes);
} catch (err) {
console.log(`${(err as Error).name}: ${(err as Error).message}`);
}
}
console.log(`listCovers: ${(await listCovers()).map((c) => c.isbn).join(", ")}`);
console.log(`glob: ${(await Array.fromAsync(glob("uploads/**/*.jpg"))).join(", ")}`);
saved 9780441172719: 48213 bytes
saved 9780547928227: 35870 bytes
InvalidCoverError: cover must be a JPEG file
InvalidCoverError: invalid ISBN-13: ../../src/server
listCovers: 9780441172719, 9780547928227
glob: uploads/covers/9780441172719.jpg, uploads/covers/9780547928227.jpg
The PNG and the record with an invalid ISBN were rejected; we will see why in the security section below.
FileHandle: Close What You Open
To read part of a file rather than all of it, you get a FileHandle with open. The handle holds an operating system resource (a file descriptor) and must be closed. JavaScript now has a counterpart of C#'s await using var stream = File.OpenRead(...): FileHandle supports Symbol.asyncDispose and V8 14.6 runs await using.
import { open } from "node:fs/promises";
// Reads the first bytes of a file; the handle is closed even if read() throws
async function readMagic(file: string): Promise<string> {
await using handle = await open(file, "r");
const { buffer, bytesRead } = await handle.read(Buffer.alloc(4), 0, 4, 0);
return buffer.subarray(0, bytesRead).toString("hex");
}
// The older form of the same thing
async function readMagicFinally(file: string): Promise<string> {
const handle = await open(file, "r");
try {
const { buffer, bytesRead } = await handle.read(Buffer.alloc(4), 0, 4, 0);
return buffer.subarray(0, bytesRead).toString("hex");
} finally {
await handle.close();
}
}
console.log(await readMagic("data/catalog-sample.csv"), await readMagicFinally("data/catalog-sample.csv"));
efbbbf69 efbbbf69
If you forget to close it, Node tries to close the descriptor when the garbage collector collects the handle and prints a warning, but the documentation explicitly tells you not to rely on that. On a busy server, unclosed handles end in an EMFILE: too many open files error. The efbbbf in the output is no coincidence either; we will come back to it shortly.
path: join, resolve and Operating System Differences
import path from "node:path";
console.log(path.join("uploads", "covers", "9780441172719.jpg"));
console.log(path.join("uploads", "/etc/passwd"));
console.log(path.resolve("uploads", "/etc/passwd"));
console.log(path.join("uploads/covers", "../../src/server.ts"));
console.log(path.win32.join("C:\\bookstore", "uploads", "covers"));
console.log(path.extname("cover.final.JPG"), path.basename("/data/catalog.csv", ".csv"));
console.log(import.meta.dirname === path.dirname(import.meta.filename));
uploads/covers/9780441172719.jpg
uploads/etc/passwd
/etc/passwd
src/server.ts
C:\bookstore\uploads\covers
.JPG catalog
true
join concatenates the parts with the separator and normalizes the result; even when it sees an absolute part, it appends it like any other. resolve builds an absolute path from right to left and drops everything to the left as soon as it sees an absolute part: /etc/passwd became the result directly. Both process .., so user input joined with join can still escape the folder. C#'s Path.Combine also drops the first part when the second one is rooted; it behaves like resolve.
The output uses / on Linux and macOS and \ on Windows; the path module follows the rules of the operating system it runs on. If you want a specific system's rules, pick path.posix or path.win32 explicitly. Don't concatenate paths by hand with "/", and watch letter case when comparing file names: Windows and the default macOS file system are case-insensitive, Linux is case-sensitive. A cover.JPG that works on Windows can become a file that cannot be found in a Linux container.
For a module's own folder, ESM has import.meta.dirname (03). Relative paths, on the other hand, are resolved against process.cwd(), the folder the process was started in. That is why uploads/covers in Bookstore can be changed with the COVERS_DIR environment variable and is turned into an absolute path once at startup.
Buffer and Encodings: Turkish Characters, Chunked Reads and the BOM
Buffer is Node's byte array; it derives from Uint8Array, so it sits somewhere between C#'s byte[] and Span<byte>. Reading a file without an encoding returns a Buffer, reading it with "utf8" returns a string. Supported encodings include utf8, utf16le, latin1, base64, base64url and hex; Windows-1254 (Turkish Windows) is not among them. For Windows-1254 files from older systems you can use new TextDecoder("windows-1254").
The real trap is data that arrives in pieces: chunks read from a stream, packets from a socket. When a chunk boundary falls in the middle of a multi-byte character:
import { StringDecoder } from "node:string_decoder";
// Bytes arrive in chunks; a chunk boundary can split a multi-byte character
const bytes = Buffer.from("Çalıkuşu;Reşat Nuri Güntekin", "utf8");
const chunks = [bytes.subarray(0, 5), bytes.subarray(5, 9), bytes.subarray(9)];
let naive = "";
for (const chunk of chunks) {
naive += chunk.toString("utf8");
}
console.log(`naive: ${naive}`);
const decoder = new TextDecoder("utf-8");
let streamed = "";
for (const chunk of chunks) {
streamed += decoder.decode(chunk, { stream: true });
}
streamed += decoder.decode();
console.log(`TextDecoder: ${streamed}`);
const sd = new StringDecoder("utf8");
console.log(`StringDecoder: ${chunks.map((c) => sd.write(c)).join("") + sd.end()}`);
console.log(`base64: ${Buffer.from("Çalıkuşu").toString("base64")}`);
naive: Çal��ku��u;Reşat Nuri Güntekin
TextDecoder: Çalıkuşu;Reşat Nuri Güntekin
StringDecoder: Çalıkuşu;Reşat Nuri Güntekin
base64: w4dhbMSxa3XFn3U=
Decoding each chunk separately split ı and ş in two and broke them. TextDecoder with stream: true and Node's StringDecoder keep the incomplete bytes for the next chunk. If your test data is ASCII you will never see this bug; that is one reason to use test data with Turkish characters. On streams, setEncoding("utf8") does the same job (09).
The efbbbf at the start of the file is the UTF-8 BOM. Excel adds it when it saves "CSV UTF-8", and readFile(..., "utf8") does not strip it:
import { readFile } from "node:fs/promises";
const text = await readFile(new URL("../../data/catalog-sample.csv", import.meta.url), "utf8");
const firstColumn = text.split(/\r?\n/)[0].split(";")[0];
console.log(firstColumn, firstColumn.length, firstColumn === "isbn", firstColumn.charCodeAt(0).toString(16));
isbn 5 false feff
The screen shows isbn, but its length is 5 and it is not equal to "isbn"; the first character is U+FEFF. .NET's StreamReader detects and skips the BOM by default; Node does not. Strip the BOM yourself from every text file that comes from users.
Bookstore: Importing the Catalog from CSV
The store team hands over new books as CSV exported from Excel. Excel with Turkish locale settings uses ; as the separator and , as the decimal mark, and the line endings are \r\n. The sample file:
isbn;title;author;price;currency
9780441172719;Dune;Frank Herbert;349,90;TRY
9780547928227;The Hobbit;J.R.R. Tolkien;289,50;TRY
9780141439600;Middlemarch;George Eliot;310,00;TRY
9780141439587;Emma;Jane Austen;245,00;TRY
9789750719387;Çalıkuşu;Reşat Nuri Güntekin;185,00;TRY
9780141439518;Pride and Prejudice;Jane Austen;12,99;EUR
9780141439519;Persuasion;Jane Austen;11,50;EUR
9780141441146;Jane Eyre;Charlotte Bronte;abc;TRY
// Excel's "CSV UTF-8" in a Turkish locale: optional BOM, ';' separator, ',' decimal mark
export function parseCatalogCsv(text: string): CatalogImportResult {
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
const header = lines[0].split(";").map((c) => c.trim().toLowerCase());
if (header.join(";") !== HEADER.join(";")) {
throw new Error(`unexpected header: ${header.join(";")}`);
}
const books: Book[] = [];
const errors: string[] = [];
lines.slice(1).forEach((line, index) => {
const lineNo = index + 2;
if (line.trim() === "") {
return;
}
const fields = line.split(";").map((f) => f.trim());
if (fields.length !== HEADER.length) {
errors.push(`line ${lineNo}: expected ${HEADER.length} fields, got ${fields.length}`);
return;
}
const [isbn, title, author, priceText, currency] = fields;
const price = Number(priceText.replace(",", "."));
if (!isValidIsbn13(isbn)) {
errors.push(`line ${lineNo}: invalid ISBN-13 ${isbn}`);
} else if (!Number.isFinite(price) || price <= 0) {
errors.push(`line ${lineNo}: invalid price ${priceText}`);
} else if (!CURRENCIES.includes(currency)) {
errors.push(`line ${lineNo}: unknown currency ${currency}`);
} else {
books.push({ isbn, title, author, price, currency: currency as Currency });
}
});
return { books, errors };
}
// Small files only: the whole file is read into memory (large files: streams, next post)
export async function readCatalogCsv(csvPath: string): Promise<CatalogImportResult> {
return parseCatalogCsv(await readFile(csvPath, "utf8"));
}
The parser is deliberately simple: it does not support fields that contain ; inside quotes. For a real import, use a library that implements RFC 4180; our focus here is the file and encoding side. isValidIsbn13 comes from the @bookstore/contracts package we set up in 04. The script writes the result to data/catalog.json and exits with code 1 if any row was skipped, so you won't miss the problem in CI or in a scheduled task:
node scripts/import-catalog.ts data/catalog-sample.csv; echo "exit=$?"
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
imported 6 books into data/catalog.json, 2 rows skipped
exit=1
In 13 we will load this JSON into BookstoreDb on SQL Server. The write goes through writeFileAtomic; the next section explains why.
Atomic Writes and Concurrent Write Races
writeFile first truncates the file, then writes the content piece by piece. Anyone reading the file in the meantime sees a partial file. Let a writer replace a 7.6 MB catalog file 20 times while a reader keeps reading it:
// A reader polls the file while a writer replaces it 20 times
async function run(label: string, write: (data: string) => Promise<void>): Promise<void> {
await writeFileAtomic(target, catalog(0));
let writing = true;
let reads = 0;
let broken = 0;
const reader = (async () => {
while (writing) {
const text = await readFile(target, "utf8");
reads++;
try {
JSON.parse(text);
} catch {
broken++;
}
}
})();
for (let n = 1; n <= 20; n++) {
await write(catalog(n));
}
writing = false;
await reader;
console.log(`${label.padEnd(16)} reads: ${reads}, half-written: ${broken}`);
}
await run("writeFile", (data) => writeFile(target, data));
await run("writeFileAtomic", (data) => writeFileAtomic(target, data));
writeFile reads: 62, half-written: 61
writeFileAtomic reads: 31, half-written: 0
With plain writeFile, 61 of 62 reads saw a file that could not be parsed. With the atomic version, none did. The fix is to write the content to a temporary file in the same folder first and then move it over the target with rename:
import { writeFile, rename, rm } from "node:fs/promises";
import { randomUUID } from "node:crypto";
// Readers see either the old file or the new one, never a half-written file
export async function writeFileAtomic(target: string, data: string | Uint8Array): Promise<void> {
const temp = `${target}.${randomUUID()}.tmp`;
try {
await writeFile(temp, data);
await rename(temp, target);
} catch (err) {
await rm(temp, { force: true });
throw err;
}
}
Within one file system, rename is atomic on POSIX systems; on Windows libuv performs a move that replaces the target, but if another process (an antivirus scanner, a backup tool) holds the target open you can get EPERM. That is also why the temporary file is in the same folder as the target: you cannot rename across disks or volumes (EXDEV). If you also want to survive a power cut, you need to flush the file to disk before the rename; we will see an unexpected interaction between that and the Permission Model below.
An atomic write protects readers, not two writers. Here are 50 concurrent reservations doing read-modify-write on the same file:
// Read-modify-write without coordination: 50 concurrent reservations
async function reserveOne(): Promise<void> {
const stock = JSON.parse(await readFile(file, "utf8"));
stock.reserved += 1;
await writeFile(file, JSON.stringify(stock));
}
await Promise.all(Array.from({ length: 50 }, reserveOne));
const final = JSON.parse(await readFile(file, "utf8"));
console.log(`expected 50, file says ${final.reserved}`);
for i in 1 2 3 4 5 6; do node labs/08/lost-update.ts 2>&1 | grep -E "expected|SyntaxError"; done
expected 50, file says 1
expected 50, file says 2
expected 50, file says 1
SyntaxError: Unexpected end of JSON input
expected 50, file says 1
expected 50, file says 1
Even though we run on a single thread there is a race: at every await another reservation gets in, they all read the same old value and overwrite each other. Of 50 reservations only 1–2 survived, and in one run a half-written file was read and the process crashed. The Node documentation also says that promise-based file system operations are not synchronized with each other and that concurrent modifications to the same file can corrupt data. Shared, changing data belongs in a database, not in a file; for stock we will use a transaction in 13. If it has to stay in a file, route the writes through a single queue.
One last note: watching a file for changes with fs.watch is not consistent across platforms. The same save produces two events on some systems, the file name is missing on others, and on network drives and some container mounts it does not work at all. Don't rely on watch for things like reloading configuration; restart the process or use an explicit reload endpoint.
Path Traversal and the Permission Model
When a value from the user becomes part of a file path, escaping the allowed folder with ../ is called path traversal. A common defence is to resolve the path and check whether it starts with the root folder. But that check has a trap of its own:
import path from "node:path";
import { resolveInsideRoot } from "#storage/safePath";
const root = "/srv/bookstore/uploads/covers";
// Naive check: does the resolved path start with the root string?
function naiveResolve(requested: string): string {
const full = path.resolve(root, requested);
if (!full.startsWith(root)) {
throw new Error(`blocked ${requested}`);
}
return full;
}
function attempt(resolve: () => string): string {
try {
return `allowed ${resolve()}`;
} catch {
return "blocked";
}
}
for (const requested of ["9780441172719.jpg", "../../../../etc/passwd", "../covers-backup/keys.txt", decodeURIComponent("..%2F..%2Fconfig.json")]) {
console.log(requested);
console.log(` naive: ${attempt(() => naiveResolve(requested))}`);
console.log(` safe: ${attempt(() => resolveInsideRoot(root, requested))}`);
}
9780441172719.jpg
naive: allowed /srv/bookstore/uploads/covers/9780441172719.jpg
safe: allowed /srv/bookstore/uploads/covers/9780441172719.jpg
../../../../etc/passwd
naive: blocked
safe: blocked
../covers-backup/keys.txt
naive: allowed /srv/bookstore/uploads/covers-backup/keys.txt
safe: blocked
../../config.json
naive: blocked
safe: blocked
covers-backup passed the naive check because it starts with covers. The right check appends the separator to the root before comparing:
// Resolves a user-supplied relative path and refuses anything outside root
export function resolveInsideRoot(root: string, requested: string): string {
const absoluteRoot = path.resolve(root);
const full = path.resolve(absoluteRoot, requested);
if (full !== absoluteRoot && !full.startsWith(absoluteRoot + path.sep)) {
throw new PathOutsideRootError(requested);
}
return full;
}
Decode a value that comes from a URL before this check; if you check first and decode afterwards, %2F turns into / after the check. The best defence, though, is that the user never chooses the file name at all. In coverStorage the file name can only be a valid ISBN-13; resolveInsideRoot is a second layer. We also check the content by its first bytes, not by its extension: a JPEG starts with FF D8 FF. Path traversal in general is covered in the Web Security (OWASP Top 10) series, and file uploads on the .NET side in the ASP.NET Core Web API series post on file upload and download.
function coverPath(isbn: string): string {
// First defence: only a valid ISBN-13 can become a file name
if (!isValidIsbn13(isbn)) {
throw new InvalidCoverError(`invalid ISBN-13: ${isbn}`);
}
// Second defence: the resolved path must stay under the covers folder
return resolveInsideRoot(coversRoot, `${isbn}.jpg`);
}
The Permission Model: Restricting the Process
To limit where the process can write on disk even when the code has a bug, Node has the Permission Model; it has been stable since 23.5. It is enabled with --permission and rejects file system, network, child process and worker access that has not been explicitly allowed, with ERR_ACCESS_DENIED. Let's grant write access to the cover folder and try:
mkdir -p uploads
node --permission --allow-fs-read=. --allow-fs-write=./uploads labs/08/covers.ts
node --permission --allow-fs-read=. --allow-fs-write=./uploads scripts/import-catalog.ts data/catalog-sample.csv
saved 9780441172719: 48213 bytes
saved 9780547928227: 35870 bytes
...
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
node:internal/fs/promises:1720
binding.mkdir(
^
Error: Access to this API has been restricted. Use --allow-fs-write to manage permissions.
at mkdir (node:internal/fs/promises:1720:13)
at file:///home/user/bookstore-api/scripts/import-catalog.ts:17:7 {
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemWrite',
resource: 'data'
}
The covers were written and writing to data/ was denied. I learned three things in these experiments. First, if the folder you allow does not exist yet, mkdir was denied; you need to create it beforehand or use a wildcard such as ./uploads*. Second, the first version of writeFileAtomic used writeFile(temp, data, { flush: true }) for durability, and under the Permission Model I got this error:
Error [ERR_ACCESS_DENIED]: fsync API is disabled when Permission Model is enabled.
at fsync (node:internal/fs/promises:1694:11)
...
at async writeFileAtomic (file:///home/user/bookstore-api/src/storage/atomicWrite.ts:8:9)
flush calls fsync underneath, and calls that work on a file descriptor, such as fsync, are disabled under the Permission Model. For Bookstore's covers I chose the Permission Model and dropped flush; on a power cut the last cover may be lost, but no half-written file is left behind. Third, the network is restricted too: starting the API without --allow-net made listen fail with Use --allow-net to manage permissions. The documentation describes the model as a seat belt: it limits accidental damage by code you trust, it is not a boundary against malicious code. Symlinks can lead outside the allowed folders. For isolation, containers and operating system permissions remain the real layer.
In Production
Uploaded files disappear with the container. When the container is recreated, uploads/ is gone; with several replicas each one has a different folder. In production put covers on a volume or in object storage (Azure Blob, S3); the coverStorage interface stays the same and only its implementation changes.
Watch disk and descriptor limits. Unclosed handles show up as EMFILE and a full disk as ENOSPC; both come as error codes, so always log err.code.
Account for the thread pool. An API doing heavy file work shares the same 4 threads with crypto and dns.lookup (06).
Always state the encoding. readFile(path) returns a Buffer, readFile(path, "utf8") a string; document the encoding and BOM status of the files you import.
Common Pitfalls
readFileSync in a request handler. Even for a small file it blocks the event loop and makes every request wait under load.
Checking paths with startsWith(root). Without the separator, sibling folders such as covers-backup pass the check.
Decoding chunks one by one with toString(). Turkish characters break at chunk boundaries; use TextDecoder or setEncoding.
Forgetting the BOM. The first column name becomes "isbn" and the header check fails silently.
Keeping shared state in a file. A single thread does not prevent races; every await is a window for one.
A Small Experiment
★☆☆ Change the text in labs/08/decode-chunks.ts to "Iğdır;Ödemiş" and move the chunk boundaries so that they split every Turkish character. How many � do you see?
★★☆ Extend parseCatalogCsv so that when the same ISBN appears twice, the second row is reported as an error. Add a duplicate row to the sample file and check the script's exit code.
★★★ Fix the race in labs/08/lost-update.ts with a small SerialQueue class that serializes the writes (each job chained to the previous job's promise). Measure that 50 reservations stay 50, and the total time.
Everything in this post loaded the whole file into memory. In the next post we hit the limit of that: we will produce a 500 MB order export with Readable, Transform and Writable streams, pipeline() and backpressure, without blowing up memory, and write it gzipped to an HTTP response.