Node.js Async Programming: Callbacks, Promises, and Async/Await

Node.js Async Programming: Callbacks, Promises, and Async/Await

이 글의 핵심

A practical guide to asynchronous JavaScript in Node.js: escape callback hell, use Promises and async/await, understand the event loop, and handle streams safely.

Introduction

What is asynchronous programming?

Asynchronous code does not block the caller while waiting for I/O; other work can proceed.

console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// 1, 3, then 2

Why Node uses async:

  • Non-blocking I/O keeps the server responsive under load
  • High concurrency on a single thread for I/O-bound workloads
  • Less CPU wasted waiting on disks and networks

Other ecosystems: compare with C++ Asio async I/O, Rust concurrency, and browser async JavaScript.


1. Callbacks

Error-first callbacks

const fs = require('fs');

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

Callback hell

Chaining multiple async steps nests callbacks and duplicates error handling:

const fs = require('fs');

fs.readFile('file1.txt', 'utf8', (err1, data1) => {
    if (err1) {
        console.error(err1);
        return;
    }
    fs.readFile('file2.txt', 'utf8', (err2, data2) => {
        if (err2) {
            console.error(err2);
            return;
        }
        fs.readFile('file3.txt', 'utf8', (err3, data3) => {
            if (err3) {
                console.error(err3);
                return;
            }
            console.log(data1, data2, data3);
        });
    });
});

Issues: readability, repeated if (err), hard debugging. Fix: Promises or async/await.


2. Promises

Creating a Promise

function delay(ms) {
    return new Promise((resolve, reject) => {
        if (ms < 0) {
            reject(new Error('ms must be non-negative'));
            return;
        }
        setTimeout(() => resolve(`${ms}ms done`), ms);
    });
}

delay(1000)
    .then((result) => console.log(result))
    .catch((err) => console.error(err.message));

Chaining

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

fs.readFile('file1.txt', 'utf8')
    .then((data1) => {
        console.log('file1:', data1);
        return fs.readFile('file2.txt', 'utf8');
    })
    .then((data2) => {
        console.log('file2:', data2);
        return fs.readFile('file3.txt', 'utf8');
    })
    .then((data3) => console.log('file3:', data3))
    .catch((err) => console.error('Error:', err.message))
    .finally(() => console.log('done'));

Static helpers

Promise.all:

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

Promise.all([
    fs.readFile('file1.txt', 'utf8'),
    fs.readFile('file2.txt', 'utf8'),
    fs.readFile('file3.txt', 'utf8')
])
    .then(([a, b, c]) => console.log(a, b, c))
    .catch((err) => console.error(err.message));

Promise.allSettled:

Promise.allSettled([
    fs.readFile('file1.txt', 'utf8'),
    fs.readFile('file2.txt', 'utf8'),
    fs.readFile('missing.txt', 'utf8')
]).then((results) => {
    results.forEach((r, i) => {
        if (r.status === 'fulfilled') console.log(i, r.value);
        else console.log(i, r.reason.message);
    });
});

Promise.race:

Promise.race([
    delay(1000).then(() => '1s'),
    delay(2000).then(() => '2s'),
    delay(500).then(() => '0.5s')
]).then((result) => console.log('Fastest:', result));

Promise.any:

Promise.any([
    Promise.reject('e1'),
    Promise.reject('e2'),
    Promise.resolve('ok')
]).then(console.log).catch(console.error);

3. Async/await

async functions always return a Promise. await pauses only the async function until the Promise settles; the event loop can still run other work.

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

async function readFiles() {
    try {
        const data1 = await fs.readFile('file1.txt', 'utf8');
        const data2 = await fs.readFile('file2.txt', 'utf8');
        const data3 = await fs.readFile('file3.txt', 'utf8');
        console.log(data1, data2, data3);
        return 'all read';
    } catch (err) {
        console.error('Error:', err.message);
        throw err;
    }
}

readFiles()
    .then((msg) => console.log(msg))
    .catch((err) => console.error('Final error:', err.message));

Sequential vs parallel

Sequential (slower):

async function sequential() {
    const start = Date.now();
    await delay(1000);
    await delay(1000);
    await delay(1000);
    console.log(Date.now() - start); // ~3000ms
}

Parallel (faster):

async function parallel() {
    const start = Date.now();
    await Promise.all([delay(1000), delay(1000), delay(1000)]);
    console.log(Date.now() - start); // ~1000ms
}

4. Event loop (overview)

   ┌───────────────────────────┐
┌─>│           timers          │  setTimeout, setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  I/O callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  setImmediate
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──│      close callbacks      │
   └───────────────────────────┘

Typical ordering example:

console.log('1. sync');

setTimeout(() => console.log('4. setTimeout'), 0);
setImmediate(() => console.log('5. setImmediate'));

Promise.resolve().then(() => console.log('3. Promise microtask'));

console.log('2. sync');

process.nextTick(() => console.log('3b. nextTick'));

Rule of thumb: run sync code first, then nextTick, then other microtasks (Promises), then timers / I/O / setImmediate depending on context. Avoid starving the loop with excessive nextTick.


5. util.promisify

Wrap callback-style APIs:

const fs = require('fs');
const util = require('util');
const readFile = util.promisify(fs.readFile);

async function main() {
    const data = await readFile('file.txt', 'utf8');
    console.log(data);
}
main();

6. Streams

Streams process data in chunks instead of loading entire files into memory.

const fs = require('fs');

const readStream = fs.createReadStream('large-file.txt', {
    encoding: 'utf8',
    highWaterMark: 64 * 1024
});

readStream.on('data', (chunk) => console.log('chunk', chunk.length));
readStream.on('end', () => console.log('done'));
readStream.on('error', (err) => console.error(err.message));

const writeStream = fs.createWriteStream('output.txt');
writeStream.write('line1\n');
writeStream.end('last\n');
writeStream.on('finish', () => console.log('written'));

Pipe and gzip:

const fs = require('fs');
const zlib = require('zlib');

fs.createReadStream('input.txt')
    .pipe(zlib.createGzip())
    .pipe(fs.createWriteStream('input.txt.gz'));

Transform:

const { Transform } = require('stream');

class UpperCaseTransform extends Transform {
    _transform(chunk, encoding, callback) {
        this.push(chunk.toString().toUpperCase());
        callback();
    }
}

fs.createReadStream('input.txt')
    .pipe(new UpperCaseTransform())
    .pipe(fs.createWriteStream('output.txt'));

7. Practical examples

Full examples in this guide cover: file pipeline, fetch with retry, concurrency limit, timeout with Promise.race, download helper, batch processor, and simple crawler. Translate log messages to your team’s language in your projects.


8. Common pitfalls

Unhandled rejections

Always await inside try/catch or attach .catch(), and consider:

process.on('unhandledRejection', (reason) => {
    console.error('Unhandled rejection:', reason);
});

Missing await

// Wrong: data is a Promise
const data = fetchData();

// Right
const data = await fetchData();

forEach does not await

Use for...of with await, or Promise.all with map.


Summary

StyleReadabilityError handling
CallbacksPoor when nestedPer callback
PromisesBetter chaining.catch / end of chain
async/awaitBest for linear flowtry/catch

Next steps

Resources