Skip to content
aviral gupta

// B3.3 · ~34 min · Beginner

Writing, appending and atomic replace

After this lesson you can write, append and refuse to overwrite files, save readable JSON, and replace a file so that it never holds half a write.

Lesson 3 of 5 in B3 Files and paths

You will be able to

  • Replace a file with writeFile, add to it with appendFile, and refuse to overwrite with the wx flag
  • Save a value as readable JSON, into a folder that mkdir creates when it is missing
  • Replace a file atomically: write a temp file in the same folder, then rename it over the target
  1. Warm-up · Activity 1 of 7

    Warm-up from B3.2: readFile("missing.txt", "utf8") rejects because there is no such file. Which error.code does the error carry?

  2. Predict · Activity 2 of 7

    Predict before you read on. notes.txt holds the line buy milk. What does this program print?

    import {readFile, writeFile} from 'node:fs/promises';
    
    await writeFile('notes.txt', 'call Ada\n');
    console.log(await readFile('notes.txt', 'utf8'));
  3. Practice · Activity 3 of 7

    app.log already holds started. Fill in the function so that stopped is added as a second line and started stays.

    await ____("app.log", "stopped\n");
    await ("app.log", "stopped\n");
  4. Practice · Activity 4 of 7

    f.txt holds old. Match each flag of writeFile("f.txt", "new\n", {flag}) to what happens.

  5. Practice · Activity 5 of 7

    Fill in the third argument so that settings.json is indented by two spaces per level, and ends with a newline.

    await writeFile("settings.json", JSON.stringify(settings, null, ____) + "\n");
    await writeFile("settings.json", JSON.stringify(settings, null, ) + "\n");
  6. Brain teaser · Activity 6 of 7

    Brain teaser. settings.json holds {"theme": "light"}. The program below saves the new settings with a temp file, but dies before the rename. What does settings.json hold afterwards?

    import {rename, writeFile} from 'node:fs/promises';
    
    await writeFile('.settings.json.tmp', '{"theme": "dark"}\n');
    throw new Error('power cut'); // the program dies here
    await rename('.settings.json.tmp', 'settings.json');
  7. Apply · Activity 7 of 7

    Mini-task. Write main.js so that node main.js "buy milk" adds the note to data/notes.json next to main.js, a JSON array indented by two spaces. A missing folder or file must simply start an empty list. Replace notes.json with a temp file and rename, append a line added: buy milk to data/notes.log, and print how many notes there are. Run it twice and open both files.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A log, a refusal and a settings file

This program creates the folder out next to main.js and writes a log there: writeFile starts it, appendFile adds two lines. A second writeFile with the wx flag refuses to overwrite the log and reports EEXIST. Last, it saves settings as indented JSON the safe way, through a temp file and rename, and lists the folder: no temp file is left. Run it twice and the output stays the same, because writeFile starts the log afresh.

main.js

import {appendFile, mkdir, readFile, readdir, rename, writeFile} from 'node:fs/promises';
import {join} from 'node:path';

const dir = join(import.meta.dirname, 'out');
await mkdir(dir, {recursive: true}); // fine if out already exists

// writeFile replaces, appendFile adds.
const log = join(dir, 'app.log');
await writeFile(log, 'started\n');
await appendFile(log, 'saved settings\n');
await appendFile(log, 'stopped\n');
console.log((await readFile(log, 'utf8')).trimEnd());

// 'wx' refuses to overwrite an existing file.
try {
  await writeFile(log, 'oops\n', {flag: 'wx'});
} catch (error) {
  console.log('wx:', error.code);
}

// JSON, replaced in one step: temp file in the same folder, then rename.
const settings = join(dir, 'settings.json');
const temp = join(dir, `.settings.json.${process.pid}.tmp`);
await writeFile(temp, JSON.stringify({theme: 'dark', fontSize: 14}, null, 2) + '\n');
await rename(temp, settings);
console.log((await readFile(settings, 'utf8')).trimEnd());
console.log('in out:', (await readdir(dir)).sort().join(', '));

Run it with

node main.js

Output

started
saved settings
stopped
wx: EEXIST
{
  "theme": "dark",
  "fontSize": 14
}
in out: app.log, settings.json
  • appendFile added two lines after started, because each piece of data ends with "\n".
  • With {flag: 'wx'}, writeFile rejects with EEXIST and app.log keeps its three lines.
  • JSON.stringify(value, null, 2) put each property on its own line, two spaces deep.
  • The temp file was renamed over settings.json, so the folder holds only the two real files.

Exercises

Exercise 1 of 2

A temp name next to the target

Before an atomic replace, you need a temp path in the same folder as the target. Write tempPathFor(target, id): put a dot before the file name and .<id>.tmp after it, and keep the folder part unchanged. "data/notes.json" with id 42 gives "data/.notes.json.42.tmp"; "notes.json" gives ".notes.json.42.tmp". Paths use / here. This part is pure string work, so it runs in the browser too.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    target.lastIndexOf("/") finds the last slash; it is -1 when there is none.

  2. Hint 2

    Adding 1 gives the index where the file name starts, also 0 when there is no slash.

  3. Hint 3

    target.slice(0, cut) is the folder part with its slash, target.slice(cut) the file name.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// The temp path for an atomic replace: same folder, a dot before the name,
// .<id>.tmp after it. "data/notes.json", 42 -> "data/.notes.json.42.tmp"
export function tempPathFor(target, id) {
  const cut = target.lastIndexOf("/") + 1;
  return target.slice(0, cut) + "." + target.slice(cut) + "." + id + ".tmp";
}

console.log(tempPathFor("data/notes.json", 42));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// The temp path for an atomic replace: same folder, a dot before the name,
// .<id>.tmp after it. "data/notes.json", 42 -> "data/.notes.json.42.tmp"
export function tempPathFor(target, id) {
  return target + ".tmp";
}

console.log(tempPathFor("data/notes.json", 42));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {tempPathFor} from './main.js';

test('a file in a folder keeps its folder', () => {
  const got = tempPathFor('data/notes.json', 42);
  assert.equal(got, 'data/.notes.json.42.tmp', `tempPathFor returned ${JSON.stringify(got)}`);
});

test('a file without a folder', () => {
  const got = tempPathFor('notes.json', 7);
  assert.equal(got, '.notes.json.7.tmp', `tempPathFor returned ${JSON.stringify(got)}`);
});

test('a deep absolute path', () => {
  const got = tempPathFor('/home/ada/app/data/notes.json', 1234);
  assert.equal(got, '/home/ada/app/data/.notes.json.1234.tmp', `tempPathFor returned ${JSON.stringify(got)}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

saveJson, safely

saveJson(path, value) writes value straight into path, and it fails because the folder data does not exist yet. Make it safe: create the folder of path with mkdir and {recursive: true}, write JSON.stringify(value, null, 2) plus "\n" to a temp file in that folder, such as .notes.json.<process.pid>.tmp, then rename the temp file over path. The code below it saves twice and prints the file and the folder. Run node main.js and check with node --test.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    dirname(path) is the folder; mkdir(dirname(path), {recursive: true}) does nothing when it already exists.

  2. Hint 2

    Build the temp path in the same folder: join(dir, `.${basename(path)}.${process.pid}.tmp`).

  3. Hint 3

    writeFile(temp, JSON.stringify(value, null, 2) + "\n"), then rename(temp, path).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import {mkdir, readFile, readdir, rename, writeFile} from 'node:fs/promises';
import {basename, dirname, join} from 'node:path';

// Saves value as indented JSON, replacing path in one step.
export async function saveJson(path, value) {
  const dir = dirname(path);
  await mkdir(dir, {recursive: true});
  const temp = join(dir, `.${basename(path)}.${process.pid}.tmp`);
  await writeFile(temp, JSON.stringify(value, null, 2) + '\n');
  await rename(temp, path);
}

const file = join(import.meta.dirname, 'data', 'notes.json');
await saveJson(file, {notes: ['buy milk']});
await saveJson(file, {notes: ['buy milk', 'call Ada']});
console.log((await readFile(file, 'utf8')).trimEnd());
console.log('ends with a newline:', (await readFile(file, 'utf8')).endsWith('\n'));
console.log('files in data:', (await readdir(dirname(file))).sort().join(', '));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {mkdir, readFile, readdir, rename, writeFile} from 'node:fs/promises';
import {basename, dirname, join} from 'node:path';

// Saves value as indented JSON, replacing path in one step.
export async function saveJson(path, value) {
  await writeFile(path, JSON.stringify(value));
}

const file = join(import.meta.dirname, 'data', 'notes.json');
await saveJson(file, {notes: ['buy milk']});
await saveJson(file, {notes: ['buy milk', 'call Ada']});
console.log((await readFile(file, 'utf8')).trimEnd());
console.log('ends with a newline:', (await readFile(file, 'utf8')).endsWith('\n'));
console.log('files in data:', (await readdir(dirname(file))).sort().join(', '));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';

test('runs and creates the folder data', async () => {
  await runMain();
});

test('notes.json holds the second save, indented by two spaces', async () => {
  const got = (await runMain()).split('\n').slice(0, 6).join('\n');
  const expected = '{\n  "notes": [\n    "buy milk",\n    "call Ada"\n  ]\n}';
  assert.equal(got, expected, `the file starts with ${JSON.stringify(got)}`);
});

test('notes.json ends with a newline', async () => {
  const out = await runMain();
  assert.match(out, /ends with a newline: true/, 'the file does not end with "\\n"');
});

test('no temp file is left in data', async () => {
  const last = (await runMain()).trimEnd().split('\n').at(-1);
  assert.equal(last, 'files in data: notes.json', `the folder holds: ${last}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Writing an object instead of text

import {writeFile} from 'node:fs/promises';

await writeFile('settings.json', {theme: 'dark'});

What Node.js prints

TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received an instance of Object

Why, and the fix

writeFile writes text or bytes, and since v14 it no longer turns other values into strings for you. Turn the value into JSON first: writeFile("settings.json", JSON.stringify(settings, null, 2) + "\n").

Writing into a folder that does not exist

import {writeFile} from 'node:fs/promises';

await writeFile('data/notes.json', '[]\n');

What Node.js prints

Error: ENOENT: no such file or directory, open 'data/notes.json'

Why, and the fix

writeFile creates the file, but not the folders on its way. Create the folder first: await mkdir("data", {recursive: true}). With recursive it creates every missing parent and does not fail when the folder is already there.

Renaming onto the folder instead of the file

import {mkdir, rename, writeFile} from 'node:fs/promises';

await mkdir('data', {recursive: true});
await writeFile('.notes.json.tmp', '[]\n');
await rename('.notes.json.tmp', 'data'); // meant data/notes.json

What Node.js prints

Error: EISDIR: illegal operation on a directory, rename '.notes.json.tmp' -> 'data'

Why, and the fix

rename takes the full new path of the file, not the folder it should land in. rename overwrites an existing file, but if there is a folder at the new path it raises an error instead. Write rename(".notes.json.tmp", "data/notes.json").

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Replace, append or refuse

writeFile(path, data) from node:fs/promises creates the file, or replaces everything in it: its default flag 'w' truncates an existing file. appendFile(path, data) adds data at the end and creates the file if it is missing; its flag is 'a'. Neither adds a newline, so end each line with "\n" yourself. The flag option changes what writeFile does: {flag: 'a'} appends, and {flag: 'wx'} creates the file but fails with EEXIST if the path already exists, so you never overwrite something by accident. If the folder in the path does not exist, writing fails with ENOENT.

JSON on disk, and a folder to put it in

writeFile takes a string or bytes, not an object: since v14 it no longer turns other values into strings, and passing {theme: 'dark'} throws a TypeError. Turn the value into text first. JSON.stringify(value, null, 2) indents by two spaces, which makes the file readable and its changes easy to compare; add "\n" so the file ends with a newline, as text files usually do. When the folder may be missing, create it first with mkdir(dir, {recursive: true}): it creates every missing parent and does not fail when the folder already exists. B3.4 covers folders in depth.

Atomic replace: temp file, then rename

writeFile first empties the file and then writes. If the program dies in between, or another program reads at that moment, it finds an empty or half-written file. The safe pattern: write the complete new text to a temp file in the same folder, such as .settings.json.123.tmp, then rename(temp, target). The fs docs say that when the new path already exists, rename overwrites it, so the old file is replaced in one step. Until then, the target still holds its old, complete content. Choose a temp name that no other run uses, for example with process.pid, and never rename onto a folder: that raises EISDIR.

Sources

Last reviewed October 3, 2026