Async/Await und Fehlerbehandlung in Node.js
1. Übersicht
async/await hilft dir, asynchronen Code in Node.js zu schreiben, der wie synchroner Code aussieht, viel lesbarer als Callbacks oder Ketten von .then()-Aufrufen. Aber wenn du die zugrunde liegenden Mechanismen nicht wirklich verstehst, schreibst du sehr leicht fehlerhafte Error-Handling-Muster, was zu UnhandledPromiseRejection führt und deine gesamte Anwendung zum Absturz bringt.
Dieser Artikel führt dich von der Funktionsweise von async/await, über korrekte Error-Handling-Patterns, bis zu häufigen Fehlern bei der Kombination mit Promise.all, Schleifen und Express-Middleware. Nach dem Lesen wirst du wissen, wie man sichere async-Funktionen schreibt und asynchrone Fehler schneller debuggt.
2. Wie Async/Await Funktioniert
Eine Funktion, die mit async deklariert ist, gibt immer ein Promise zurück, egal welchen Wert du darin return-est. await “pausiert” nur an dem Punkt des Aufrufs und gibt die Kontrolle an die Event Loop ab, es blockiert keinen Thread.
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
const user = await response.json();
return user;
}
getUser(1).then(user => console.log(user.name));
Die Zeile await fetch(...) wartet darauf, dass das Promise von fetch resolved wird, bevor sie fortfährt. Wenn fetch reject wird (z.B. bei Netzwerkproblemen), wird der Fehler genau an dem await-Punkt geworfen — und wenn du ihn nicht abfängst, wird auch das Promise zurückgewiesen, das getUser zurückgibt.
Hinweis: Eine
async functiongibt immer ein Promise zurück, auch wenn du darin keinawaitverwendest. Es aufzurufen undawaitoder.then()zu vergessen, ist die häufigste Ursache für den Bug “Code wird nicht in der richtigen Reihenfolge ausgeführt”.
3. Fehler mit try/catch abfangen
Die beste Praktik, um Fehler in einer async-Funktion zu behandeln, ist, try/catch um die await-Punkte zu legen.
async function getOrder(orderId) {
try {
const order = await orderRepository.findById(orderId);
if (!order) {
throw new Error(`Order ${orderId} not found`);
}
return order;
} catch (err) {
console.error('Failed to get order:', err.message);
throw err; // re-throw, damit der Aufrufer Bescheid weiß und es weiterverarbeiten kann
}
}
tryumgibt die gesamte Logik, die werfen oder rejected werden könnte.catch (err)fängt sowohl manuell geworfene Fehler als auch durch Promise-Ablehnung verursachte Fehler ab.throw erram Ende, um den Fehler nicht zu “verschlucken” — der Aufrufer erhält immer noch das abgelehnte Promise.
Hinweis: Wenn du einen Fehler
catch-est, ihn aber nichtthrow-est und keinen gültigen Wertreturn-est, wird die async-Funktion mitundefinedaufgelöst. Der Aufrufer denkt, alles ist in Ordnung, aber die Daten sind unvollständig — ein sehr schwer zu erkennender Bug.
3.1. Benutzerdefinierte Error-Klasse
Um Fehler klar zu klassifizieren (Geschäftsfehler, Systemfehler…), solltest du deine eigene Error-Klasse erstellen.
class PaymentError extends Error {
constructor(message, code) {
super(message);
this.name = 'PaymentError';
this.code = code;
}
}
async function chargeCard(order) {
const result = await paymentGateway.charge(order.total);
if (!result.success) {
throw new PaymentError('Card declined', 'CARD_DECLINED');
}
return result;
}
Wenn du einen Fehler abfängst, kannst du err instanceof PaymentError überprüfen, um verschiedene Fehlertypen unterschiedlich zu behandeln, anstatt Nachrichten-Strings zu vergleichen, die fehlerbehaftet sein können.
4. Fehler in Promise.all und Promise.allSettled
Promise.all führt mehrere Promises parallel aus, aber wenn einer rejected, wird das Ganze sofort rejected, die anderen Promises laufen zwar noch “im Hintergrund” weiter, aber ihre Ergebnisse werden ignoriert.
async function notifyCustomers(customers) {
const results = await Promise.all(
customers.map(c => emailService.send(c.email))
);
return results;
}
Wenn emailService.send für den zweiten Kunden fehlschlägt, wird die gesamte Funktion sofort rejected — du weißt nicht, ob die Kunden 1, 3, 4 erfolgreich benachrichtigt wurden oder nicht.
Wenn du die Ergebnisse aller Promises kennenlernen möchtest, auch wenn einige fehlschlagen, verwendest du Promise.allSettled:
async function notifyCustomersSafely(customers) {
const results = await Promise.allSettled(
customers.map(c => emailService.send(c.email))
);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length > 0) {
console.warn(`${failed.length} email(s) failed to send`);
}
return results;
}
Promise.allSettled wird niemals rejected. Jedes Element in results hat die Form { status: 'fulfilled', value } oder { status: 'rejected', reason }. Du entscheidest selbst, wie du weiterverarbeitest.
[
{ status: 'fulfilled', value: true },
{ status: 'rejected', reason: Error: SMTP timeout },
{ status: 'fulfilled', value: true }
]
5. Häufige Fehler bei der Verwendung von Await in Schleifen
await in forEach zu verwenden, ist ein sehr häufiger Fehler — der Callback von forEach wartet nicht auf asynchrone Operationen.
// FALSCH: forEach wartet nicht auf async
async function processOrders(orders) {
orders.forEach(async order => {
await orderService.process(order);
});
console.log('Done'); // läuft sofort, wartet nicht, bis die Bestellungen verarbeitet sind
}
forEach ruft den async-Callback auf und ignoriert dann das zurückgegebene Promise, es wartet nicht auf die Auflösung. console.log('Done') wird ausgeführt, bevor eine Bestellung verarbeitet ist — wenn process einen Fehler wirft, wird dieser nirgendwo abgefangen, was zu UnhandledPromiseRejection führt.
Behebe dies mit for...of, wenn du die Bestellungen sequenziell verarbeiten musst:
async function processOrdersSequentially(orders) {
for (const order of orders) {
await orderService.process(order);
}
console.log('Done');
}
Oder verwende Promise.all mit map, wenn du parallel verarbeiten möchtest:
async function processOrdersParallel(orders) {
await Promise.all(orders.map(order => orderService.process(order)));
console.log('Done');
}
6. Fehlerbehandlung in Express-Middleware
Express fängt standardmäßig keine Fehler von async-Funktionen in Route-Handlern ab — wenn du darin einen Fehler wirfst, wird die Request hängen bleiben oder der Prozess in älteren Node-Versionen crashen (Express 4 fängt Promise-Rejections nicht automatisch ab).
// RISIKO: Async-Fehler werden von Express nicht automatisch abgefangen
app.get('/orders/:id', async (req, res) => {
const order = await orderService.getOrder(req.params.id);
res.json(order);
});
Der sichere Weg ist, einen Wrapper zu schreiben, der Fehler an next() übergibt:
function asyncHandler(fn) {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
}
app.get('/orders/:id', asyncHandler(async (req, res) => {
const order = await orderService.getOrder(req.params.id);
if (!order) {
const err = new Error('Order not found');
err.statusCode = 404;
throw err;
}
res.json(order);
}));
app.use((err, req, res, next) => {
res.status(err.statusCode || 500).json({ message: err.message });
});
asyncHandler umhüllt den Route-Handler; wenn das zurückgegebene Promise rejected wird, leitet .catch(next) den Fehler an die Error-Handling-Middleware von Express weiter, anstatt ihn einfach hängenzulassen.
Hinweis: Express 5 (zum Zeitpunkt des Schreibens noch experimentell) fängt Rejections von async-Handlern bereits automatisch ab, aber mit Express 4, das weit verbreitet ist, brauchst du immer noch diesen Wrapper.
7. Timeout und AbortController
Ein leicht zu übersehender Fehler: Wenn du auf ein Promise wartest, das sich niemals aufgelöst wird, bleibt die Funktion unendlich hängen. Verwende AbortController, um die Wartezeit zu begrenzen.
async function fetchWithTimeout(url, timeoutMs = 3000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
setTimeout ruft controller.abort() nach timeoutMs auf und lässt fetch mit einem Fehler namens AbortError rejectionieren. finally bereinigt immer den Timer, um Memory Leaks zu vermeiden, egal ob Erfolg oder Fehler.
8. Tests für Async-Funktionen schreiben
Verwende Jest, um sowohl erfolgreiche als auch fehlerhafte Fälle zu testen.
<!-- package.json (dependencies-Abschnitt, nicht Maven) -->
npm install --save-dev jest
// orderService.js
async function getOrder(orderId, repository) {
const order = await repository.findById(orderId);
if (!order) {
throw new Error(`Order ${orderId} not found`);
}
return order;
}
module.exports = { getOrder };
// orderService.test.js
const { getOrder } = require('./orderService');
describe('getOrder', () => {
it('gibt die Bestellung zurück, wenn sie gefunden wird', async () => {
const mockRepo = { findById: jest.fn().mockResolvedValue({ id: 1, total: 100 }) };
const order = await getOrder(1, mockRepo);
expect(order).toEqual({ id: 1, total: 100 });
});
it('wirft einen Fehler, wenn die Bestellung nicht gefunden wird', async () => {
const mockRepo = { findById: jest.fn().mockResolvedValue(null) };
await expect(getOrder(99, mockRepo)).rejects.toThrow('Order 99 not found');
});
});
mockResolvedValue simuliert ein Promise, das mit einem vorgegebenen Wert aufgelöst wird. rejects.toThrow überprüft, dass das Promise mit der erwarteten Nachricht abgelehnt wird — das ist die richtige Art, asynchrone Fehler zu testen, anstatt try/catch manuell in Tests zu verwenden.
PASS ./orderService.test.js
getOrder
✓ gibt die Bestellung zurück, wenn sie gefunden wird (3 ms)
✓ wirft einen Fehler, wenn die Bestellung nicht gefunden wird (2 ms)
9. Häufige Fehler, die du vermeiden solltest
awaitvergessen: Eine async-Funktion aufrufen, aber nichtawait-en lässt den Code nicht in der richtigen Reihenfolge ausgeführt werden, und Fehler werden “stillschweigend geschluckt”.- Gemischte
.then()undawait: verwirrt leicht, wähle nur einen Stil in derselben Funktion. try/catchzu breit: das Wrappen von nicht-asynchroner Logik macht es schwer zu sehen, woher der Fehler kommt.- Nicht re-throw in catch: Der Aufrufer erhält
undefinedstatt des Fehlers, was zu stillen Bugs in der nächsten Schicht führt. forEachmit async-Callback verwenden: es wartet nicht, verwende stattdessenfor...ofoderPromise.all.
Hinweis: Wenn deine Logik keine I/O oder asynchronen Operationen hat, musst du die Funktion nicht als
asynckennzeichnen — es erhöht nur unnötig den Overhead der Promise-Erstellung.
10. Fazit
async/await macht asynchronen Code in Node.js lesbarer, aber die korrekte Fehlerbehandlung ist der entscheidende Teil für stabile Anwendungen. Wickle immer try/catch um deine await-Punkte, re-throw Fehler wenn nötig, verwende Promise.allSettled, wenn du die Ergebnisse aller parallel laufenden Tasks kennenlernen möchtest, und vergiss nicht den Wrapper für Express-Route-Handler. Tests für erfolgreiche und fehlerhafte Fälle schreiben gibt dir mehr Vertrauen beim späteren Refaktorisieren asynchroner Logik.