Writing & Appending Files
Writing data to disk is one of the most common things a Node.js program does — saving configuration, emitting logs, exporting reports, or persisting uploads. The fs module gives you three distinct tools for the job: writeFile for replacing a file’s contents in one shot, appendFile for adding to the end of a file, and createWriteStream for feeding large or continuous data without holding it all in memory. Picking the right one, and understanding the flags that govern how a file is opened, is what keeps your writes correct and your memory usage flat.
Writing a whole file with writeFile
fs.writeFile (and its promise counterpart in node:fs/promises) replaces a file’s entire contents with the data you pass. If the file does not exist it is created; if it does, it is truncated first. This is the right tool when you already have the complete value in memory — a JSON document, a rendered template, a small text file.
import { writeFile } from "node:fs/promises";
const config = { port: 3000, logLevel: "info" };
await writeFile("config.json", JSON.stringify(config, null, 2), "utf8");
console.log("config.json saved");
Output:
config.json saved
The third argument is the options object (or an encoding shortcut string). When you pass a string as the data, the encoding defaults to "utf8", so the "utf8" above is explicit rather than strictly required.
Appending with appendFile
fs.appendFile writes to the end of a file instead of overwriting it, creating the file first if it is missing. It is the natural fit for log files and audit trails where each call should add a line without disturbing what came before.
import { appendFile } from "node:fs/promises";
const line = `${new Date().toISOString()} request handled\n`;
await appendFile("access.log", line, "utf8");
Each call opens the file, seeks to the end, writes, and closes it. That is fine for occasional appends, but for a high-frequency log you should keep a write stream open instead (see below) rather than paying the open/close cost on every line.
appendFileis essentiallywriteFilewith the flag set to"a". Anything you can express withappendFileyou can also express withwriteFileand{ flag: "a" }.
The flag option
Both writeFile and appendFile accept a flag in their options object that controls how the underlying file is opened. The flag decides whether existing content is kept, replaced, or whether the operation should fail when the file already exists.
| Flag | Meaning | If file exists | If file missing |
|---|---|---|---|
w | Write (default for writeFile) | Truncated to empty, then written | Created |
a | Append (default for appendFile) | Kept, data added to end | Created |
wx | Write, exclusive | Fails with EEXIST | Created |
ax | Append, exclusive | Fails with EEXIST | Created |
r+ | Read/write | Opened, not truncated | Fails with ENOENT |
The exclusive flags are useful when you must not clobber an existing file — for example, claiming a lock file or writing a one-time setup marker:
import { writeFile } from "node:fs/promises";
try {
await writeFile("install.lock", "locked", { flag: "wx" });
console.log("Lock acquired");
} catch (err) {
if (err.code === "EEXIST") {
console.log("Already installed, skipping");
} else {
throw err;
}
}
Output:
Lock acquired
Writing buffers vs strings
You can hand writeFile either a string or a Buffer (or any TypedArray). When you pass a string, Node encodes it using the encoding option — "utf8" by default. When you pass a Buffer, the bytes are written verbatim and the encoding option is ignored, which is exactly what you want for binary data like images or compressed payloads.
import { writeFile } from "node:fs/promises";
// String: encoded as UTF-8 text
await writeFile("note.txt", "Héllo, world", "utf8");
// Buffer: raw bytes, no encoding applied
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
await writeFile("magic.bin", bytes);
Reach for a Buffer whenever the data is not human-readable text. Forcing binary data through a string encoding can silently corrupt it.
Streaming large writes with createWriteStream
writeFile buffers the whole payload in memory before writing it. For large files, or for data that arrives incrementally (an HTTP download, generated rows, a transform pipeline), use fs.createWriteStream. A write stream lets you push chunks over time and only holds a small buffer at any moment.
import { createWriteStream } from "node:fs";
const stream = createWriteStream("report.csv", { flags: "w" });
stream.write("id,name\n");
for (let i = 1; i <= 1000; i++) {
stream.write(`${i},user-${i}\n`);
}
stream.end();
stream.on("finish", () => console.log("report.csv written"));
stream.on("error", (err) => console.error("Write failed:", err.code));
Output:
report.csv written
Note that the stream option is named flags (plural) and uses the same values from the table above. Always call stream.end() when you are done, and listen for finish to know the data is fully flushed to disk. For piping a readable source into a file, prefer stream/promises’ pipeline, which handles errors and cleanup for you:
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
const res = await fetch("https://example.com/large.zip");
await pipeline(res.body, createWriteStream("large.zip"));
console.log("download complete");
Mixing manual
write()calls withpipelineon the same stream leads to interleaved, corrupt output. Pick one approach per stream.
CommonJS consumers import these the same way with require:
const { createWriteStream } = require("node:fs");
Best Practices
- Use
writeFilewhen the full contents fit comfortably in memory; switch tocreateWriteStreamfor large or incremental data. - Use
appendFile(or flag"a") for logs so you never truncate existing entries. - Reach for the
wx/axexclusive flags when overwriting an existing file would be a bug, and handle theEEXISTerror. - Pass a
Bufferfor binary data and a string with explicit"utf8"for text — never round-trip binary through a text encoding. - Always
end()a write stream and listen for bothfinishanderror; preferpipelinewhen copying from a readable source. - Keep a single long-lived write stream for high-frequency logging instead of calling
appendFileper line.