Writing and Appending Files
Writing data to disk is one of the most common jobs a Node.js program has — saving an uploaded file, generating a report, writing a config file, or appending a new line to a log every time something happens. Node’s built-in fs module gives you three ways to do this: a promise-based API for modern async/await code, a synchronous API for simple one-off scripts, and a stream-based API for writing large amounts of data efficiently. This lesson covers all three, the flags that control overwrite vs. append behavior, and the mistakes that trip up most beginners.
Overview: How Writing Files Works in Node.js
Every write to disk ultimately goes through the operating system’s file APIs, and Node exposes them in the node:fs module in three flavors:
- Promise-based (
node:fs/promises) — the recommended API for almost all application code. Methods return aPromise, so youawaitthem and wrap them intry/catch. - Synchronous (
fs.writeFileSync,fs.appendFileSync) — blocks the entire process until the write finishes. Fine for command-line tools and one-time startup scripts; dangerous inside anything that handles concurrent requests. - Callback-based (
fs.writeFile,fs.appendFile) — the original Node.js API, using error-first callbacks. Still common in older code and some libraries, but new code should prefer the promise API.
Under the hood, the asynchronous variants do not block the JavaScript event loop. Node hands the actual file operation off to libuv’s thread pool (four threads by default), which performs the blocking system call on a worker thread. When the operation finishes, libuv queues the result back onto the event loop’s poll phase, which is where your callback or resolved promise actually runs. This is why you can fire off a file write and keep handling other requests at the same time — the disk I/O happens off the main thread, and only the small bit of bookkeeping (invoking your callback) happens on it.
The synchronous methods skip all of that: they make a blocking system call directly on the main thread, so nothing else — no other request, no timer, no I/O callback — can run until the write completes. That is the core tradeoff you are making every time you reach for a Sync method.
Overwrite vs. Append
fs.writeFile() and fs.writeFileSync() open the file with the "w" flag by default, which creates the file if it does not exist and truncates it (erases existing contents) if it does. fs.appendFile() and fs.appendFileSync() use the "a" flag, which creates the file if needed but adds new data to the end of the existing content instead of erasing it. Both accept a flag option so you can override this — for example "wx" creates a file only if it does not already exist, and fails with an EEXIST error otherwise, which is useful for avoiding accidental overwrites.
Data can be a string, a Buffer, or (for the promise and callback APIs) a TypedArray. Strings are encoded to bytes using the encoding option, which defaults to "utf8".
Syntax
await fs.writeFile(path, data, options);
await fs.appendFile(path, data, options);
fs.writeFileSync(path, data, options);
| Parameter | Description |
|---|---|
path |
File path (string, Buffer, or URL) to write to. |
data |
The content to write — a string, Buffer, or TypedArray. |
options |
Either an encoding string ("utf8") or an object: { encoding, mode, flag, signal }. |
options.flag |
"w" (default for write, truncate) · "a" (default for append) · "wx"/"ax" (fail if the file exists) · "r+" (read/write, no truncate). |
The synchronous and callback versions accept the same path, data, and options arguments; the callback version simply takes an extra (err) => {} callback instead of returning a promise.
Examples
Example 1: Writing a File with the Promise API
const fs = require("node:fs/promises");
async function main() {
try {
await fs.writeFile("notes.txt", "Hello from Node.js!\n", "utf8");
console.log("File written successfully");
} catch (err) {
console.error("Failed to write file:", err.message);
}
}
main();
Output:
File written successfully
This creates notes.txt if it doesn’t exist, or completely replaces its contents if it does, because the third argument "utf8" is shorthand for { encoding: "utf8", flag: "w" }. The try/catch around the await catches problems like a missing parent directory or a permissions error.
Example 2: Appending Log Entries
const fs = require("node:fs/promises");
async function logEvent(message) {
const line = `[${new Date().toISOString()}] ${message}\n`;
try {
await fs.appendFile("app.log", line, "utf8");
} catch (err) {
console.error("Failed to write log:", err.message);
}
}
async function main() {
await logEvent("Server started");
await logEvent("User logged in");
console.log("Log entries written");
}
main();
Output:
Log entries written
Each call to logEvent adds a new line to the end of app.log instead of erasing what’s already there, because appendFile opens the file with the "a" flag. Awaiting each call in turn also guarantees the two lines land in the file in the order they were logged.
Example 3: Writing JSON Safely with the “wx” Flag
const fs = require("node:fs/promises");
async function saveConfig(config) {
try {
await fs.writeFile("config.json", JSON.stringify(config, null, 2), {
encoding: "utf8",
flag: "wx",
});
console.log("Config created");
} catch (err) {
if (err.code === "EEXIST") {
console.error("config.json already exists - refusing to overwrite");
} else {
throw err;
}
}
}
saveConfig({ port: 3000, env: "production" });
Output:
Config created
The first time this runs, it creates config.json with pretty-printed JSON (the 2 passed to JSON.stringify is the indentation width). If you run the script again without deleting the file, the "wx" flag makes the write fail with an EEXIST error instead of silently clobbering the existing config, and the catch block reports that clearly instead of crashing.
Example 4: Writing Large Output with a Stream and Backpressure
const fs = require("node:fs");
function writeManyLines(filePath, count) {
return new Promise((resolve, reject) => {
const stream = fs.createWriteStream(filePath, { encoding: "utf8" });
stream.on("error", (err) => reject(err));
stream.on("finish", () => resolve());
let i = 0;
function writeNext() {
let ok = true;
while (i < count && ok) {
i++;
const line = `line ${i}\n`;
if (i === count) {
stream.end(line);
} else {
ok = stream.write(line);
}
}
if (i < count) {
stream.once("drain", writeNext);
}
}
writeNext();
});
}
writeManyLines("big-output.txt", 100000)
.then(() => console.log("Finished writing 100000 lines"))
.catch((err) => console.error("Write failed:", err.message));
Output:
Finished writing 100000 lines
Instead of building one 100,000-line string in memory and calling writeFile once, this writes line by line through a WriteStream. stream.write() returns false when the stream’s internal buffer is full — that’s backpressure. The loop stops writing and waits for the "drain" event, which fires once the buffered data has actually been flushed to disk, before resuming. Without this check, writing faster than the disk can keep up would pile up an unbounded amount of data in memory.
Under the Hood: What Happens When You Call writeFile
When you call await fs.writeFile(path, data), here is the order of operations:
- Node’s JavaScript layer validates the arguments and converts your
pathanddatainto the low-level types libuv expects. - Node asks libuv to schedule the operation on its thread pool (size 4 by default, configurable via the
UV_THREADPOOL_SIZEenvironment variable). Your JavaScript code returns immediately with a pendingPromise— the event loop is free to run timers, handle other I/O, or process other requests. - On a worker thread, libuv makes the actual blocking system calls: it opens the file descriptor (creating or truncating as the flag dictates), writes the bytes, then closes the descriptor.
- When the worker thread finishes, libuv places the result on the event loop’s poll phase queue.
- On the next pass through the poll phase, Node resolves your promise (or invokes your callback) with the result — success or an
Errorobject.
Code after your await, or inside a .then(), runs only after that round trip completes. This is also why firing several writes in a loop without awaiting each one can reorder them: they’re all scheduled onto the thread pool at roughly the same time, with no guarantee about which reaches disk first. If order matters, await each write before starting the next.
Common Mistakes
Mistake 1: Blocking the Event Loop with Sync Writes in a Server
It’s tempting to reach for the simpler synchronous API everywhere, but inside a request handler it stalls every other connection:
const http = require("node:http");
const fs = require("node:fs");
http.createServer((req, res) => {
// BAD: blocks the event loop on every single request
fs.writeFileSync("requests.log", `${new Date().toISOString()} ${req.url}\n`, { flag: "a" });
res.writeHead(200);
res.end("OK");
}).listen(3000);
Every request now waits for every other request’s disk write to finish, because writeFileSync blocks the single JavaScript thread the whole server runs on. Under real traffic this serializes requests that should be handled concurrently. Use the promise API instead so the write happens off-thread and other requests keep flowing:
const http = require("node:http");
const fsp = require("node:fs/promises");
http.createServer((req, res) => {
fsp.appendFile("requests.log", `${new Date().toISOString()} ${req.url}\n`)
.catch((err) => console.error("Log write failed:", err.message));
res.writeHead(200);
res.end("OK");
}).listen(3000);
Mistake 2: Using writeFile Where You Meant appendFile
This loop is meant to build up a file line by line, but each iteration erases the last one:
const fs = require("node:fs/promises");
async function main() {
const entries = ["first", "second", "third"];
for (const entry of entries) {
await fs.writeFile("output.txt", entry + "\n");
}
}
main();
// output.txt ends up containing only "third" - each write erased the previous one
Because writeFile defaults to the "w" flag, each call truncates the file before writing, so only the last entry survives. Swap in appendFile to add to the file instead of replacing it:
for (const entry of entries) {
await fs.appendFile("output.txt", entry + "\n");
}
Mistake 3: Leaving a Write Promise Unhandled
A promise-returning call that is neither awaited nor given a .catch() is easy to write by accident:
fs.writeFile("data.txt", data); // no await, no catch - if this rejects, the process gets an unhandled rejection
If the write fails — a full disk, a bad path, a permissions error — the rejection has nowhere to go. Recent Node.js versions terminate the process on an unhandled promise rejection by default. Always await the call inside a try/catch, or attach an explicit .catch() if you intentionally want it to run in the background.
Best Practices
- Prefer the promise-based
node:fs/promisesAPI in application code; reserveSyncmethods for CLI scripts and one-time startup code that runs before you start accepting requests. - Be explicit about the
encodingoption when writing text, even though"utf8"is the default — it documents intent and avoids surprises if a Buffer is passed instead. - Use the
"wx"/"ax"flags when a write should never silently clobber an existing file, and handle the resultingEEXISTerror. - For large files or continuous output, use
fs.createWriteStream()instead of building one giant string in memory and writing it in a single call. - Always attach an
errorlistener to write streams — an unhandled stream error can crash the process. - When writing many chunks to a stream, check the boolean returned by
stream.write()and wait for thedrainevent before writing more, so you don’t buffer unlimited data in memory. - Wrap every awaited file write in
try/catch, or attach.catch()to writes you don’tawait— never leave a promise floating. - Build paths with
path.join()rather than string concatenation so writes don’t depend on the process’s current working directory or the host OS’s path separator.
Practice Exercises
- Write an async function
saveJSON(filePath, obj)that writes a JavaScript object to a file as pretty-printed JSON usingfs.writeFile. Run it twice and confirm the second run replaces the file’s contents rather than adding to them. - Write a function
appendTimestampedLog(message)that appends a line like[2026-07-31T10:00:00.000Z] messagetoactivity.logeach time it’s called. Call it five times in a row and inspect the file — are all five lines present, and in the order you called them? - Using
fs.createWriteStream, write a script that generates a file with 50,000 rows ofid,valuepairs, one per line. Add anerrorlistener and afinishlistener that logs when the write completes, and make sure your writer respects backpressure.
Summary
fs.writeFile()/fs.writeFileSync()create or truncate a file (flag"w"by default);fs.appendFile()/fs.appendFileSync()add to the end (flag"a").- The promise API (
node:fs/promises) is the modern default; synchronous methods block the event loop and should be limited to scripts and startup code. - Async file writes run on libuv’s thread pool and resolve on the event loop’s poll phase — they don’t block other work, but aren’t guaranteed to land in a specific order unless you
awaiteach one. - Use the
"wx"flag to fail safely instead of silently overwriting an existing file. - For large or continuous output, use
fs.createWriteStream()and respect backpressure by checkingwrite()‘s return value and waiting fordrain. - Always handle errors:
try/catcharound awaited writes,.catch()on floating promises, and anerrorlistener on every stream.
