Skip to content
JavaScript Basics: Reading Local Files with async and await

JavaScript Basics: Reading Local Files with async and await

October 14, 2026

A small Node.js script can read a report without blocking JavaScript while the file operation is pending. This walkthrough uses local text files to show successful reads, a missing-file fallback, and several reads started together.

Before you start

These two independent scripts were tested on Linux with Bash and Node.js 24.19.0. They use built-in modules only: no npm packages, package.json, network access, or custom environment variables are required. The .mjs extension selects JavaScript modules, allowing import and top-level await.

Create a fresh directory and keep this Bash session open:

start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

Save each source block as a UTF-8 file with the stated filename in this directory. Each script creates its own temporary data directory underneath it and removes that data in finally. Only the two source files remain for the final cleanup. An abrupt process termination can leave a temporary directory behind; inspect it before removing it yourself.

Wait for a write, then read it

Save as demo_async_note.mjs:

import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";

const directory = await mkdtemp("note-demo-");

try {
  const filename = join(directory, "note.txt");
  await writeFile(filename, "Review the draft.\n", {
    encoding: "utf8",
    flag: "wx",
  });

  const text = await readFile(filename, "utf8");
  assert.equal(text, "Review the draft.\n");
  console.log("Note:", text.trimEnd());

  async function readOptional(path) {
    try {
      return await readFile(path, "utf8");
    } catch (error) {
      if (error.code === "ENOENT") return "No note yet.";
      throw error;
    }
  }

  const fallback = await readOptional(join(directory, "missing.txt"));
  assert.equal(fallback, "No note yet.");
  console.log("Missing file:", fallback);
} finally {
  await rm(directory, { recursive: true });
}

Run it:

node demo_async_note.mjs

Output:

Note: Review the draft.
Missing file: No note yet.

Read the example from top to bottom. The write must finish before the read starts, so both calls use await. Supplying utf8 returns text rather than a byte buffer. The wx flag refuses to overwrite an existing file. The fresh directory keeps the demonstration away from your real notes.

The missing file is intentional. This helper treats only ENOENT as optional; other failures still propagate. Here the parent directory is known to exist, but ENOENT can also mean a missing parent path. Keep the await inside try: it lets this catch handle the rejected read. Returning its promise without awaiting it would leave that rejection to the caller.

The assertions check the complete text, including its newline, before printing a shortened display. They produce no output on success and stop the script if a result is wrong.

Read several independent inputs

Save as demo_async_reports.mjs:

import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";

const directory = await mkdtemp("reports-demo-");
const reads = [];

try {
  const names = ["first.txt", "second.txt"];
  await writeFile(join(directory, names[0]), "Ready\n", { flag: "wx" });
  await writeFile(join(directory, names[1]), "Waiting\n", { flag: "wx" });

  reads.push(...names.map((name) => readFile(join(directory, name), "utf8")));
  const results = await Promise.all(reads);
  assert.deepEqual(results, ["Ready\n", "Waiting\n"]);

  results.forEach((text, index) => {
    console.log(`${names[index]}: ${text.trimEnd()}`);
  });

  const checks = await Promise.allSettled(
    ["first.txt", "missing.txt"].map((name) =>
      readFile(join(directory, name), "utf8"),
    ),
  );
  assert.equal(checks[0].status, "fulfilled");
  assert.equal(checks[1].status, "rejected");
  assert.equal(checks[1].reason.code, "ENOENT");

  console.log("First read:", checks[0].status);
  console.log("Missing read:", checks[1].reason.code);
  assert.deepEqual(await Promise.all([]), []);
  console.log("Empty batch: 0 results");
} finally {
  await Promise.allSettled(reads);
  await rm(directory, { recursive: true });
}

Run it:

node demo_async_reports.mjs

Output:

first.txt: Ready
second.txt: Waiting
First read: fulfilled
Missing read: ENOENT
Empty batch: 0 results

The two report reads are started by map. Promise.all gives us results in input order, regardless of which read finishes first. The example therefore pairs each result with the correct filename without relying on timing. An empty input also works.

The second batch deliberately includes a missing file. Promise.allSettled waits for every read and returns a status for each, letting us inspect both success and failure. In contrast, Promise.all rejects when an input rejects; it does not cancel operations already started. The finally block drains the first batch with allSettled before cleanup, even if Promise.all rejected early. If cleanup succeeds, the original failure still propagates.

This is a small fixed batch. Starting thousands of reads at once can exhaust resources, and readFile loads each entire file into memory. For larger workloads, use bounded concurrency and consider streams. Never launch overlapping writes to the same file and assume their completion order.

Clean up

In the same Bash session:

rm -- demo_async_note.mjs demo_async_reports.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

References

Acknowledgements

This post was created with assistance from AI.

Last updated on