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
| Style | Readability | Error handling |
|---|---|---|
| Callbacks | Poor when nested | Per callback |
| Promises | Better chaining | .catch / end of chain |
| async/await | Best for linear flow | try/catch |
Next steps
- Express.js guide
- File system (
fs) - Database integration
Resources
- Node.js async_hooks (advanced)
- MDN: Promise
Related posts
- JavaScript async: Promises and async/await
- Getting started with Node.js