Als je met Node.js werkt, loop je snel tegen asynchrone code aan. Een database-query, een bestand lezen of een externe API aanroepen: het gebeurt allemaal zonder de rest van je applicatie te blokkeren. Dat noemen we async programming, en in Node.js is het geen extraatje maar de kern van hoe het platform werkt.
In dit artikel leer je hoe asynchrone code in Node.js is geëvolueerd van callbacks naar promises en uiteindelijk naar async/await. Je krijgt praktische voorbeelden, ziet welke valkuilen je moet vermijden en ontdekt welke patronen je in productie wilt gebruiken.
Waarom async programming zo belangrijk is in Node.js
Node.js draait op één thread. Dat klinkt als een beperking, maar dankzij de event loop kan het platform duizenden gelijktijdige verbindingen aan zonder voor elke request een nieuwe thread op te starten. De truc: taken die wachten op I/O worden uitbesteed aan het systeem, en Node.js gaat ondertussen verder met ander werk.
Wil je begrijpen hoe die event loop precies werkt, lees dan eerst wat is Node.js en hoe werkt de event loop. Die basiskennis maakt alles in dit artikel tastbaarder.
Zonder async programming zou je server blokkeren bij elke trage databaseoperatie. Mét async programming blijft je applicatie responsief, zelfs als één request even op resultaat moet wachten.
Het tijdperk van callbacks
Vroeger was er vooral één manier om async te programmeren in Node.js: callbacks. Een callback is een functie die je meegeeft aan een andere functie en die wordt aangeroepen zodra een taak klaar is.
const fs = require('fs');
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) {
console.error('Er ging iets mis:', err);
return;
}
console.log('Inhoud:', data);
});
Dit werkt prima voor één taak. Maar zodra je meerdere asynchrone operaties na elkaar wilt uitvoeren, raakt je code al snel diep genest.
Callback hell
getUser(id, (err, user) => {
if (err) return done(err);
getOrders(user.id, (err, orders) => {
if (err) return done(err);
getInvoice(orders[0].id, (err, invoice) => {
if (err) return done(err);
sendEmail(user.email, invoice, (err) => {
if (err) return done(err);
done(null, 'Klaar!');
});
});
});
});
Dit fenomeen heet callback hell of de pyramid of doom. De code werkt, maar lezen, debuggen en onderhouden wordt een nachtmerrie. Elke laag extra inspringing maakt het onoverzichtelijker.
Promises: een modernere aanpak
Een promise is een object dat een toekomstige waarde vertegenwoordigt. Het kan drie toestanden hebben:
- pending, de operatie loopt nog
- fulfilled, de operatie is geslaagd, er is een resultaat
- rejected, de operatie is mislukt, er is een fout
Je handelt een promise af met .then(), .catch() en .finally().
const fs = require('fs/promises');
fs.readFile('data.txt', 'utf8')
.then((data) => {
console.log('Inhoud:', data);
})
.catch((err) => {
console.error('Er ging iets mis:', err);
});
Het mooie is dat je promises kunt chainen. Elk .then() geeft een nieuwe promise terug, dus je kunt ze achter elkaar plakken zonder diepe nesting.
getUser(id)
.then((user) => getOrders(user.id))
.then((orders) => getInvoice(orders[0].id))
.then((invoice) => sendEmail(invoice))
.then(() => console.log('Klaar!'))
.catch((err) => console.error('Fout in de keten:', err));
Eén enkele .catch() vangt fouten uit de hele keten op. Dat is een enorme verbetering ten opzichte van callbacks.
Een eigen promise maken
Soms wil je een oudere callback-gebaseerde functie omvormen tot een promise:
function wachtEnGroet(naam) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (!naam) {
reject(new Error('Naam ontbreekt'));
} else {
resolve(`Hallo, ${naam}!`);
}
}, 1000);
});
}
wachtEnGroet('Sander').then(console.log);
Voor callback-functies die het gebruikelijke (err, result) patroon volgen, biedt Node.js util.promisify:
const { promisify } = require('util');
const fs = require('fs');
const readFileAsync = promisify(fs.readFile);
Async/await: async code die leest als synchrone code
Async/await werd in Node.js 7.6 standaard beschikbaar en is inmiddels de voorkeursstijl. Het is syntactische suiker bovenop promises, maar het verschil in leesbaarheid is enorm.
async function verwerkBestelling(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const invoice = await getInvoice(orders[0].id);
await sendEmail(user.email, invoice);
return 'Klaar!';
} catch (err) {
console.error('Er ging iets mis:', err);
throw err;
}
}
Vergelijk dit met de callback-versie hierboven. Dezelfde logica, maar nu lees je het van boven naar beneden als een gewoon verhaal.
Regels om te onthouden
awaitwerkt alleen binnen eenasyncfunctie (of op het top-level in een ES module)- Een
asyncfunctie geeft altijd een promise terug - Fouten vang je op met
try/catch, precies zoals bij synchrone code - Een
returnbinnen een async functie resolvet de promise - Een
throwrejecteert de promise
Parallelle versus sequentiële uitvoering
Een klassieke valkuil: je gebruikt await in een lus of achter elkaar, terwijl de operaties onafhankelijk zijn.
// Sequentieel, traag
const user = await getUser(1);
const product = await getProduct(42);
const stock = await getStock(42);
Als deze drie calls niet van elkaar afhangen, draait dit onnodig drie keer zo lang als nodig. Beter:
// Parallel, sneller
const [user, product, stock] = await Promise.all([
getUser(1),
getProduct(42),
getStock(42),
]);
Promise.all() wacht tot alle promises zijn opgelost. Faalt er één, dan wordt de hele Promise.all gerejected.
Promise.allSettled, Promise.race en Promise.any
Promise.allSettled(), wacht op alle promises, geeft voor elk een status en waarde of reden. Handig als je alle resultaten wilt, ook als sommige falen.Promise.race(), resolvet of rejecteert zodra de eerste promise klaar is. Goed voor timeouts.Promise.any(), resolvet bij de eerste succesvolle promise. Rejecteert pas als ze allemaal falen.
Een timeout-patroon met Promise.race():
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
);
return Promise.race([promise, timeout]);
}
Veelvoorkomende valkuilen
Await vergeten
async function verkeerd() {
const data = getData(); // vergeten await, data is een Promise, geen waarde
console.log(data.name); // undefined of crash
}
Veel linters (zoals ESLint met de @typescript-eslint/no-floating-promises regel) waarschuwen hiervoor. Gebruik ze.
Async in forEach
Array.prototype.forEach begrijpt geen promises. Dit werkt dus niet zoals je denkt:
// Werkt NIET sequentieel, forEach wacht niet
users.forEach(async (user) => {
await processUser(user);
});
Gebruik in plaats daarvan een for...of lus voor sequentieel, of Promise.all met map voor parallel:
// Sequentieel
for (const user of users) {
await processUser(user);
}
// Parallel
await Promise.all(users.map((user) => processUser(user)));
Unhandled rejections
Een promise die rejecteert zonder .catch() of try/catch leidt tot een unhandled promise rejection. In recente Node.js-versies crasht je proces hierop. Zorg altijd voor foutafhandeling, zeker in je Node.js server routes.
Voor het centraal loggen van onverwachte fouten:
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
});
Async patronen in de praktijk
Retry met exponential backoff
async function retry(fn, retries = 3, delay = 500) {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (err) {
if (i === retries - 1) throw err;
await new Promise((r) => setTimeout(r, delay * 2 ** i));
}
}
}
Batches verwerken
Wil je 10.000 items verwerken zonder je systeem te overladen? Werk in batches:
async function inBatches(items, size, worker) {
for (let i = 0; i < items.length; i += size) {
const batch = items.slice(i, i + size);
await Promise.all(batch.map(worker));
}
}
Dit soort hulpfuncties plaats je typisch in een utils-module. Hoe je zo'n projectstructuur opzet, lees je in modules en project structuur in Node.js.
Top-level await
Sinds Node.js 14.8 kun je in ES modules await direct op het hoogste niveau gebruiken, zonder wrapper-functie:
// main.mjs
import { readFile } from 'fs/promises';
const config = JSON.parse(await readFile('./config.json', 'utf8'));
console.log(config);
Dit is handig voor startup-code, config-initialisatie en scripts. Zie de officiële MDN-documentatie over top-level await voor details.
Best practices op een rij
- Gebruik async/await voor nieuwe code, tenzij je een specifieke reden hebt voor
.then()chaining - Parallelliseer onafhankelijke operaties met
Promise.allofPromise.allSettled - Vang fouten af met
try/catchof.catch(), laat nooit een promise zweven - Vermijd
asynccallbacks inforEach,mapzonderPromise.all, offilter - Gebruik promise-varianten van Node APIs:
fs/promises,timers/promises,stream/promises - Let op geheugen bij grote parallelle batches, gebruik concurrency-limieten met bibliotheken als p-limit
- Log altijd de oorspronkelijke error, niet alleen een eigen bericht, stack traces zijn goud waard
Voor een diepgaande uitleg van hoe promises intern werken raad ik de MDN-gids over Promises aan. Voor het event loop-gedrag rond promises en microtasks is de officiële Node.js documentatie over de event loop een aanrader.
Veelgestelde vragen
Wat is async programming in Node.js?
Async programming is een manier om code te schrijven die niet-blokkerend is. Node.js kan zo meerdere taken tegelijk afhandelen, zoals database-queries of HTTP-requests, zonder dat de rest van je applicatie hoeft te wachten.
Wat is het verschil tussen een promise en async/await?
Een promise is een object dat een toekomstige waarde vertegenwoordigt. Async/await is syntactische suiker bovenop promises waarmee je asynchrone code schrijft die eruitziet als synchrone code, wat leesbaarheid en foutafhandeling sterk verbetert.
Wanneer gebruik ik Promise.all() versus Promise.allSettled()?
Gebruik Promise.all() als je wilt dat alles slaagt en een fout meteen het geheel stopt. Gebruik Promise.allSettled() als je alle resultaten wilt zien, ongeacht of sommige promises falen.
Moet ik callbacks nog gebruiken in moderne Node.js?
Voor nieuwe code meestal niet. Moderne Node.js APIs bieden promise-varianten via fs/promises en util.promisify. Callbacks kom je vooral tegen in oudere bibliotheken of bij event emitters.
Wat is callback hell en hoe voorkom ik het?
Callback hell is diep geneste callback-code die moeilijk te lezen en onderhouden is. Je voorkomt het door promises of async/await te gebruiken, die je code plat en lineair houden.