Skip to main content

Command Palette

Search for a command to run...

I Tried to Read Three Files at Once and My Code Became a Staircase

Updated
10 min read
I Tried to Read Three Files at Once and My Code Became a Staircase
S
Trying to transition my career to explore new things, new tech

I had a simple task. Read three files, combine their contents, write the result to a new file. In any other context this is maybe ten minutes of work. In Node.js, this was the afternoon I learned that JavaScript does not wait for you.

If you are coming from Python or Java or really anything where code runs line by line and waits for each thing to finish before moving on, Node.js will confuse you. It confused me. I wrote what looked like perfectly reasonable code, and the output came back empty. The file existed. The path was correct. The data just was not there yet when my code tried to use it.

This post is about why that happens, what callbacks are, why they get ugly fast, and how promises fix the mess.


Why Node.js does not wait

Most programming languages are synchronous by default. You say "read this file" and the program stops, waits for the disk to return the data, then moves to the next line. Simple. Predictable.

Node.js does not do that. When you tell Node to read a file, it sends that request to the operating system and immediately moves to the next line of code. It does not wait. The file might take 5 milliseconds or 500 milliseconds to read, and Node does not care. It has other things to do.

This is called non-blocking I/O, and the reason Node works this way is that it runs on a single thread. One thread. If it stopped and waited for every file read, every database query, every HTTP request, it could only do one thing at a time. A web server handling 1000 users would grind to a halt because user 2 is waiting for user 1's database query to finish.

So Node keeps moving. It fires off the I/O operation, continues executing whatever comes next, and when the result comes back, it runs a function you gave it earlier. That function is a callback.

Here is what it looks like when you do not account for this:

const fs = require('fs');

let content;
fs.readFile('greeting.txt', 'utf8', (err, data) => {
  content = data;
});

console.log(content);
undefined

The console.log runs before readFile finishes. By the time the callback fires and assigns data to content, the log is long gone. The variable was undefined at the moment you printed it. The data showed up a few milliseconds later, but nobody was listening anymore.

This is the first wall every beginner hits with Node. Your code runs out of order.


Callbacks: the original solution

A callback is a function you hand to another function and say "call this when you are done." Instead of waiting for a result, you describe what should happen with the result whenever it arrives.

const fs = require('fs');

fs.readFile('greeting.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Failed to read file:', err.message);
    return;
  }
  console.log(data);
});

console.log('This prints first');
This prints first
Hello from greeting.txt!

The third argument to readFile is the callback. Node reads the file in the background, and when it finishes, it calls your function with two arguments: an error (if something went wrong) and the data (if it worked). This pattern of (err, data) as the callback signature is called "error-first callbacks" and it is everywhere in older Node code.

The if (err) check at the top is not optional. If you skip it and the file does not exist, data is undefined and your code breaks in a confusing way downstream instead of failing clearly right where the problem is.

Let me walk through the flow of what actually happens at runtime:

1. Node sees fs.readFile() — sends read request to OS, moves on
2. Node sees console.log('This prints first') — prints it
3. Node has nothing left to do — enters the event loop, waits
4. OS finishes reading the file — puts callback in the queue
5. Event loop picks up the callback — runs your function
6. console.log(data) prints the file contents

The event loop is the mechanism that checks "are there any callbacks waiting to run?" in a continuous cycle. When your main code finishes executing, the event loop takes over and processes whatever I/O results have come back.


The staircase problem

One callback is fine. Two nested callbacks are tolerable. Three gets uncomfortable. The task I mentioned at the start, reading three files and combining them, looks like this with callbacks:

const fs = require('fs');

fs.readFile('header.txt', 'utf8', (err1, header) => {
  if (err1) {
    console.error('header failed:', err1.message);
    return;
  }
  fs.readFile('body.txt', 'utf8', (err2, body) => {
    if (err2) {
      console.error('body failed:', err2.message);
      return;
    }
    fs.readFile('footer.txt', 'utf8', (err3, footer) => {
      if (err3) {
        console.error('footer failed:', err3.message);
        return;
      }
      const combined = header + '\n' + body + '\n' + footer;
      fs.writeFile('page.txt', combined, (err4) => {
        if (err4) {
          console.error('write failed:', err4.message);
          return;
        }
        console.log('page.txt written');
      });
    });
  });
});

Look at the shape of that code. Every step indents further right. Four levels deep for something that is, conceptually, four sequential steps. This is what people call callback hell, and the name is earned. The logic is correct but the structure makes it genuinely hard to follow, hard to modify, and hard to handle errors consistently.

Try adding a fifth step in the middle of that. Try moving the order of operations around. Try figuring out which closing brace belongs to which callback. I spent a while counting braces and parentheses before accepting that there had to be a better way.

The error handling is especially painful. Each callback checks for its own error separately. There is no single place to catch failures. If you forget the error check in one callback, that error silently vanishes and the next step runs with undefined data.


Promises: the better way

A Promise is an object that represents a value you do not have yet but will have later. It has three states: pending (still waiting), fulfilled (got the value), or rejected (something went wrong).

Promise states:

  PENDING ——> FULFILLED (resolved with a value)
      |
      +——> REJECTED (failed with an error)

Once settled (fulfilled or rejected), a Promise never changes state again.

Node's fs module has a promise-based version built in. You get it from fs/promises:

const fs = require('fs/promises');

fs.readFile('greeting.txt', 'utf8')
  .then(data => {
    console.log(data);
  })
  .catch(err => {
    console.error('Failed:', err.message);
  });

No callback argument. readFile returns a Promise. You call .then() on it to say what happens when it succeeds, and .catch() to say what happens when it fails. The error handling is in one place instead of scattered inside every callback.

Now here is the same three-file task with promises:

const fs = require('fs/promises');

let headerText, bodyText;

fs.readFile('header.txt', 'utf8')
  .then(header => {
    headerText = header;
    return fs.readFile('body.txt', 'utf8');
  })
  .then(body => {
    bodyText = body;
    return fs.readFile('footer.txt', 'utf8');
  })
  .then(footer => {
    const combined = headerText + '\n' + bodyText + '\n' + footer;
    return fs.writeFile('page.txt', combined);
  })
  .then(() => {
    console.log('page.txt written');
  })
  .catch(err => {
    console.error('Something failed:', err.message);
  });

No staircase. The code reads top to bottom. Each .then() returns a new promise, so they chain instead of nest. And the .catch() at the bottom handles errors from any step in the chain. If body.txt fails to read, the chain skips straight to .catch() and you get one clear error message.

This is the real win. One .catch() at the end replaces four separate if (err) checks.

But we can do better. If the three files do not depend on each other, there is no reason to read them one after another. Promise.all runs them at the same time:

const fs = require('fs/promises');

Promise.all([
  fs.readFile('header.txt', 'utf8'),
  fs.readFile('body.txt', 'utf8'),
  fs.readFile('footer.txt', 'utf8'),
])
  .then(([header, body, footer]) => {
    return fs.writeFile('page.txt', header + '\n' + body + '\n' + footer);
  })
  .then(() => console.log('page.txt written'))
  .catch(err => console.error('Failed:', err.message));

Three file reads happening in parallel, results collected into an array in the same order you passed them. If any one fails, the whole thing goes to .catch(). This is both shorter and faster than the sequential version.


Making your own promises

Sometimes you are working with older libraries that only support callbacks. You can wrap them in a promise yourself:

function readFilePromise(path) {
  return new Promise((resolve, reject) => {
    const fs = require('fs');
    fs.readFile(path, 'utf8', (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

readFilePromise('greeting.txt')
  .then(data => console.log(data))
  .catch(err => console.error(err.message));

The new Promise() constructor takes a function with two arguments: resolve (call this with the value when it works) and reject (call this with the error when it fails). Once you call either one, the promise settles and .then() or .catch() fires accordingly.

Node also has util.promisify which does this wrapping for you on any function that follows the error-first callback pattern:

const { promisify } = require('util');
const fs = require('fs');

const readFile = promisify(fs.readFile);

readFile('greeting.txt', 'utf8')
  .then(data => console.log(data))
  .catch(err => console.error(err.message));

One line to convert any callback-based function into a promise-based one. This is how a lot of codebases transitioned from callbacks to promises without rewriting everything.


A few things that tripped me up

Forgetting to return inside .then(). If you start an async operation inside a .then() block but do not return it, the chain does not wait for it. The next .then() fires immediately with undefined. This is a quiet bug that does not throw errors, it just produces wrong results.

// broken — missing return
.then(header => {
  fs.readFile('body.txt', 'utf8'); // floats away, nobody waits for it
})
.then(body => {
  console.log(body); // undefined
})
// fixed
.then(header => {
  return fs.readFile('body.txt', 'utf8');
})
.then(body => {
  console.log(body); // actual file contents
})

The other thing: .catch() only catches errors from promises above it in the chain. If you put .catch() in the middle and then add more .then() calls after it, the chain continues after the catch. Sometimes that is what you want. Usually it is not, and having .catch() at the very end is the safer default.


What to actually use in 2025

If you are writing new code, use async/await. It is built on top of promises and reads like synchronous code. But you need to understand promises first because async/await is just syntax over them. When you see a confusing await error or need to run things in parallel with Promise.all, the promise knowledge is what lets you debug it.

For reference, here is the same three-file task with async/await as a preview:

const fs = require('fs/promises');

async function buildPage() {
  try {
    const [header, body, footer] = await Promise.all([
      fs.readFile('header.txt', 'utf8'),
      fs.readFile('body.txt', 'utf8'),
      fs.readFile('footer.txt', 'utf8'),
    ]);
    await fs.writeFile('page.txt', header + '\n' + body + '\n' + footer);
    console.log('page.txt written');
  } catch (err) {
    console.error('Failed:', err.message);
  }
}

buildPage();

That reads like normal code. No .then() chains, no nesting, try/catch instead of .catch(). But under the hood, every await is a promise. The next post will break that apart properly.


References