Building a ServerIntermediate8 min04 / 8

The File System

Learn how to read and write files in Node.js using the fs module — and why doing it asynchronously keeps your server fast.

One of the first superpowers Node.js gives you that a browser never could is direct access to the file system. Your server can open files, read their contents, write new data, and delete things — all from JavaScript. This is how servers save uploaded photos, write log files, read configuration, and persist data without a full database. The module that makes all of this possible is called `fs` — short for File System — and it ships with every installation of Node.js, no npm install required.

Bring it in with a single line at the top of your file:

The fs module is built-in — no installation needed. The .promises sub-object unlocks modern async/await style.
// CommonJS style — works in all Node.js versions
const fs = require('fs');

// Access the Promise-based API (we'll use this a lot)
const fsPromises = require('fs').promises;

#Sync vs Async: Why It Matters

Think of it like

A coffee shop, not a DMV

Imagine a coffee shop with one barista. The synchronous approach: the barista takes your order, then stands frozen until your espresso finishes pulling — refusing to help anyone else in line. The asynchronous approach: the barista starts the machine, immediately takes the next order, and hands the espresso over when the timer dings. Node.js runs on a single thread, just like that one barista — async keeps the line flowing.

Watch out

Sync is fine at startup — dangerous at runtime

Using readFileSync or writeFileSync before your server starts is fine — nothing is waiting on you yet. But calling them inside a request handler means every concurrent request stalls while one user's file loads. On a busy server, this causes a pile-up. Always reach for async inside handlers.

#Reading Files

There are three styles for reading a file. The sync style is a blocking one-liner. The callback style is the classic async approach — you pass a function that Node calls when the file is ready. The promises style is the modern favourite: it pairs with async/await and reads almost like synchronous code while remaining fully non-blocking.

All three read the same file. Only the sync version blocks. Prefer style 3 in new code.
const fs = require('fs');
const fsP = require('fs').promises;

// 1. Sync (blocks the event loop)
const data = fs.readFileSync('notes.txt', 'utf8');

// 2. Callback (non-blocking, classic style)
fs.readFile('notes.txt', 'utf8', (err, data) => {
  if (err) return console.error(err.message);
  console.log(data);
});

// 3. Promises + async/await (non-blocking, modern style)
async function readNote() {
  const data = await fsP.readFile('notes.txt', 'utf8');
  console.log(data);
}

#Writing and Appending Files

appendFile adds to the end of a file — perfect for logs. writeFile replaces the entire file — right for saving updated state.
const fs = require('fs').promises;
const path = require('path');

const logFile = path.join(__dirname, 'app.log');

async function logEvent(message) {
  const line = `[${new Date().toISOString()}] ${message}\n`;
  // appendFile adds to the end; writeFile replaces the whole file
  await fs.appendFile(logFile, line, 'utf8');
  console.log('Logged:', message);
}

logEvent('Server started');

#Getting Paths Right with path.join

path.join handles forward/backslashes automatically so your code works on macOS, Linux, and Windows. A path like 'config.json' is relative to wherever you ran node — __dirname is always the folder the current file lives in, making it safe regardless of where you run the script.
const fs = require('fs').promises;
const path = require('path');

// __dirname is always the folder this file lives in
const configPath = path.join(__dirname, 'config.json');

async function loadConfig() {
  try {
    const raw = await fs.readFile(configPath, 'utf8');
    return JSON.parse(raw);
  } catch (err) {
    console.error('Config not found, using defaults.');
    return { port: 3000 };
  }
}

loadConfig().then(cfg => console.log('Port:', cfg.port));
Quick check

You call `fs.readFile` inside an HTTP request handler. What is the main advantage over using `fs.readFileSync`?

A complete read-modify-write cycle. The catch block handles the first run gracefully when the file doesn't exist yet.
const fs = require('fs').promises;
const path = require('path');

const dataFile = path.join(__dirname, 'users.json');

// Real pattern: read the file, modify the data, write it back
async function addUser(name) {
  let users = [];
  try {
    const raw = await fs.readFile(dataFile, 'utf8');
    users = JSON.parse(raw);
  } catch (_) {
    // File doesn't exist yet — start with an empty list
  }
  users.push({ id: users.length + 1, name });
  await fs.writeFile(dataFile, JSON.stringify(users, null, 2), 'utf8');
  console.log(`Saved ${users.length} user(s).`);
}

addUser('Ada');

Key takeaways

  • The built-in `fs` module gives Node.js full access to the file system — no install needed.
  • Sync methods (readFileSync, writeFileSync) block the event loop — fine at startup, dangerous inside request handlers.
  • `fs.promises` with async/await is the modern, readable, non-blocking approach — prefer it.
  • Use `path.join(__dirname, 'filename')` to build reliable absolute paths that work on any OS.
  • Wrap all file reads in try/catch (or an `err` check) — files can be missing, locked, or unreadable.
Practice challenges
Test yourself · earn XP
0/4
Predict the output#1

Both appendFile and writeFile run against the same file. What gets printed?

predict-output
const fs = require('fs').promises;

async function run() {
  await fs.writeFile('log.txt', 'A', 'utf8');
  await fs.appendFile('log.txt', 'B', 'utf8');
  await fs.writeFile('log.txt', 'C', 'utf8');
  const data = await fs.readFile('log.txt', 'utf8');
  console.log(data);
}

run();
Fix the bug#2

This code runs inside an HTTP request handler on a busy server. What's the problem?

fix-bug
const fs = require('fs');

function handleRequest(req, res) {
  const data = fs.readFileSync('notes.txt', 'utf8');
  res.end(data);
}
Fill in the blank#3

Build a safe, cross-platform absolute path to 'config.json' in the same folder as this file.

const path = require('path');

const configPath = path.join(, 'config.json');
Reorder the lines#4

Order these lines into the classic read-modify-write cycle: read the file, parse it, change the data, then write it back.

1
const users = JSON.parse(raw);
2
await fs.writeFile(dataFile, JSON.stringify(users, null, 2), 'utf8');
3
const raw = await fs.readFile(dataFile, 'utf8');
4
users.push({ id: users.length + 1, name });
Your turn
Practice exercise

Write an async function called saveNote(title, content) that: (1) builds a safe absolute path to a file called note.txt in the same directory as the script using path.join and __dirname, (2) formats a string like "Title: <title>\nContent: <content>\n", (3) writes that string to the file using fs.promises.writeFile, and (4) logs 'Note saved!' when done. Call it with a title and content of your choice.

Try it yourself — a starting point to build on:

starter.js
const fs = require('fs').promises;
const path = require('path');

// TODO: Build an absolute path to 'note.txt' in this directory
const notePath = /* your code here */;

async function saveNote(title, content) {
  // TODO: Format the note text
  const text = /* your code here */;

  // TODO: Write text to notePath
  await /* your code here */;

  console.log('Note saved!');
}

// Call your function
saveNote('Shopping List', 'Eggs, milk, coffee');