Muster für die Verarbeitung von Hintergrund-Jobs
Nicht alles gehört in den Request-Response-Zyklus — Queues, Worker, Retries und Dead-Letter-Muster für eine zuverlässige Hintergrundverarbeitung.

Bestätigungs-E-Mails versenden, PDF-Berichte erzeugen, hochgeladene Bilder verarbeiten, Daten mit Drittanbieter-APIs synchronisieren — nichts davon gehört in den Request-Response-Zyklus. Diese Aufgaben sind langsam und unzuverlässig, und ihr Fehlschlag sollte die Aktion des Nutzers nicht blockieren. Die Verarbeitung von Hintergrund-Jobs verlagert diese Arbeit in eine separate Pipeline, in der sie unabhängig wiederholt, gedrosselt und überwacht werden kann.
Warum nicht einfach setTimeout verwenden?
// ❌ "Background" processing that isn't actually reliable
app.post("/api/orders", async (req, res) => {
const order = await createOrder(req.body);
// This runs in the same process — if the server crashes, the email is lost
setTimeout(async () => {
await sendConfirmationEmail(order);
await updateInventory(order);
await notifyWarehouse(order);
}, 0);
res.json(order);
});Wenn der Server neu startet, sind diese Callbacks weg. Kein Retry, keine Transparenz, keine Garantie, dass die Arbeit abgeschlossen wird. Eine richtige Job-Queue persistiert die Arbeit und übersteht Abstürze.
Das Job-Queue-Muster
Eine Job-Queue besteht aus drei Komponenten: Produzenten (stellen Arbeit ein), ein persistentes Speichersystem (hält Jobs vor) und Konsumenten/Worker (verarbeiten Jobs).
// Producer: enqueue a job after the main operation
import { Queue } from "bullmq";
import { Redis } from "ioredis";
const connection = new Redis(process.env.REDIS_URL);
const emailQueue = new Queue("email", { connection });
app.post("/api/orders", async (req, res) => {
const order = await createOrder(req.body);
// Job is persisted in Redis — survives server restarts
await emailQueue.add("order-confirmation", {
orderId: order.id,
customerEmail: order.customerEmail,
orderTotal: order.total,
});
res.json(order);
});// Consumer: process jobs from the queue
import { Worker } from "bullmq";
const emailWorker = new Worker(
"email",
async (job) => {
switch (job.name) {
case "order-confirmation":
await sendConfirmationEmail(
job.data.customerEmail,
job.data.orderId,
job.data.orderTotal,
);
break;
case "shipping-notification":
await sendShippingEmail(job.data);
break;
}
},
{
connection,
concurrency: 5, // Process 5 jobs simultaneously
},
);
emailWorker.on("completed", (job) => {
console.log(`Job ${job.id} completed`);
});
emailWorker.on("failed", (job, error) => {
console.error(`Job ${job?.id} failed:`, error.message);
});Retry-Strategien
Jobs schlagen fehl. APIs laufen in Timeouts, Dienste fallen aus, Rate-Limits werden erreicht. Eine gute Retry-Strategie behandelt vorübergehende Fehler, ohne permanente Fehler noch schlimmer zu machen.
// Exponential backoff with jitter
await emailQueue.add(
"order-confirmation",
{ orderId: order.id },
{
attempts: 5,
backoff: {
type: "exponential",
delay: 2000, // 2s, 4s, 8s, 16s, 32s
},
},
);// ❌ Retrying non-retryable errors wastes resources
async function processJob(job: Job) {
try {
await callExternalAPI(job.data);
} catch (error) {
// Retrying a 400 Bad Request will never succeed
throw error; // All errors get retried
}
}
// ✅ Distinguish retryable from permanent failures
async function processJob(job: Job) {
try {
await callExternalAPI(job.data);
} catch (error) {
if (error.status === 429 || error.status >= 500) {
throw error; // Retryable — network/server issue
}
// Permanent failure — don't retry, move to dead letter
throw new UnrecoverableError(error.message);
}
}| Fehlertyp | Beispiel | Aktion |
|---|---|---|
| Vorübergehend | Timeout, 503, Verbindung verweigert | Retry mit Backoff |
| Rate-Limit | 429 Too Many Requests | Retry mit längerer Verzögerung |
| Permanent | 400 Bad Request, ungültige Daten | Dead-Letter-Queue |
| Bug | Nicht abgefangener TypeError | Dead-Letter-Queue + Alarm |
Dead-Letter-Queues
Wenn ein Job alle Retries ausgeschöpft hat, wandert er zur Untersuchung in eine Dead-Letter-Queue. Verwirf fehlgeschlagene Jobs niemals stillschweigend.
// Configure dead letter queue behavior
const orderQueue = new Queue("orders", {
connection,
defaultJobOptions: {
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { age: 86400 }, // Keep completed for 24h
removeOnFail: false, // Never auto-delete failed jobs
},
});
// Monitor dead letter jobs
async function getDeadLetterJobs() {
const failed = await orderQueue.getFailed(0, 100);
return failed.map((job) => ({
id: job.id,
name: job.name,
data: job.data,
failedReason: job.failedReason,
attemptsMade: job.attemptsMade,
timestamp: job.timestamp,
}));
}Job-Planung
Manche Jobs müssen nach einem Zeitplan laufen — tägliche Berichte, stündliche Synchronisierungen, regelmäßige Aufräumarbeiten.
// Repeatable jobs with cron expressions
await reportQueue.add(
"daily-revenue-report",
{}, // Data can be empty for scheduled jobs
{
repeat: {
pattern: "0 8 * * *", // Every day at 8 AM
tz: "America/New_York",
},
},
);
await cleanupQueue.add(
"expire-old-sessions",
{},
{
repeat: {
pattern: "*/15 * * * *", // Every 15 minutes
},
},
);Idempotenz
Jobs können mehr als einmal zugestellt werden (At-least-once-Zustellung). Jeder Job-Handler muss dasselbe Ergebnis liefern, egal ob er einmal oder zehnmal läuft.
// ❌ Not idempotent — charging the customer multiple times
async function processPayment(job: Job) {
await stripe.charges.create({
amount: job.data.amount,
customer: job.data.customerId,
});
}
// ✅ Idempotent — uses an idempotency key to prevent duplicates
async function processPayment(job: Job) {
await stripe.charges.create(
{
amount: job.data.amount,
customer: job.data.customerId,
},
{
idempotencyKey: `order-${job.data.orderId}`,
},
);
}Verwende eindeutige Bezeichner (Bestell-ID, Job-ID) als Idempotenzschlüssel. Prüfe, ob die Arbeit bereits erledigt wurde, bevor du sie erneut ausführst.
Die wichtigsten Erkenntnisse
- Verlagere langsame, unzuverlässige Arbeit aus dem Request-Zyklus in Hintergrund-Jobs
- Persistiere Jobs in einem dauerhaften Speicher (Redis, PostgreSQL) — Verarbeitung im Arbeitsspeicher ist unzuverlässig
- Wiederhole mit exponentiellem Backoff bei vorübergehenden Fehlern, nutze Dead-Letter bei permanenten
- Jeder Job-Handler muss idempotent sein — Jobs können mehr als einmal zugestellt werden
- Verwirf fehlgeschlagene Jobs niemals stillschweigend — Dead-Letter-Queues bewahren sie für die Untersuchung auf
- Überwache Queue-Tiefe und Verarbeitungslatenz — wachsende Queues signalisieren Kapazitätsprobleme


