Durable Execution: Workflows, die Neustarts überleben
Warum Job-Queues und Datenbank-Zustandsmaschinen bei mehrstufigen Prozessen scheitern und wie Durable Execution mit Temporal Abstürze übersteht.

Jedes Backend stößt irgendwann auf einen Prozess, der nicht in einen einzigen HTTP-Request passt. Ein Auftragsabwicklungs-Flow, der eine Karte belastet, Bestand reserviert, ein Lager benachrichtigt und eine Bestätigungs-E-Mail verschickt. Ein Onboarding-Pipeline, der Cloud-Ressourcen bereitstellt, eine Datenbank befüllt und eine Willkommenssequenz sendet. Diese Workflows dauern Sekunden bis Minuten, rufen mehrere externe Services auf und müssen zuverlässig abgeschlossen werden — auch wenn der Server mitten drin abstürzt.
Die Standardantwort ist eine Job-Queue plus eine in einer Datenbank persistierte Zustandsmaschine. Sie funktioniert, bis die Zustandsmaschine auf 15 Zustände, 40 Transitionen wächst und jeder Entwickler im Team Angst hat, sie anzufassen. Es gibt ein besseres Modell: Durable Execution.
Das Problem mit Ad-hoc-Jobs mit mehreren Schritten
Eine Job-Queue kommt gut mit diskreten Arbeitseinheiten zurecht. Sie bricht zusammen, wenn ein einzelner „Job“ eigentlich eine Abfolge abhängiger Schritte mit Verzweigungslogik, externen Wartezeiten und Rollback-Anforderungen ist.
// ❌ Fragile — state lives in ephemeral memory; server restart = lost progress
async function processOrder(orderId: string) {
await chargePayment(orderId);
await reserveInventory(orderId); // If the process crashes here...
await sendConfirmationEmail(orderId); // ...this line never runs
}
// ✅ Durable — each step is checkpointed; restarts replay from the last successful step
export async function processOrderWorkflow(orderId: string): Promise<void> {
await workflow.executeActivity(chargePayment, { args: [orderId] });
await workflow.executeActivity(reserveInventory, { args: [orderId] });
await workflow.executeActivity(sendConfirmationEmail, { args: [orderId] });
}Der Unterschied ist nicht nur Retry-Logik. Es liegt daran, dass die Workflow-Engine die Ausführungshistorie persistiert, damit ein neu gestarteter Worker genau rekonstruieren kann, wo der Workflow war, ohne bereits abgeschlossene Schritte erneut auszuführen.
Was Durable Execution wirklich bedeutet
In Temporal (und ähnlichen Engines wie Inngest oder Restate) wird Workflow-Code nicht direkt gegen externe Systeme ausgeführt. Stattdessen zeichnet die Engine jede Entscheidung und jedes Aktivitätsergebnis als append-only Event-History auf. Wenn ein Worker einen Workflow aufnimmt, spielt er diese Historie wieder, um den Zustand zu rekonstruieren — und spult über abgeschlossene Aktivitäten hinweg.
Das bedeutet, deine Workflow-Funktion läuft in einer deterministischen Sandbox. Daraus ergeben sich ein paar Regeln:
- Kein direktes I/O im Workflow-Code (
fetch,fs, Datenbankabfragen) - Kein
Date.now()oderMath.random()— stattdessenworkflow.now()undworkflow.random()verwenden - Alle Seiteneffekte passieren in Activities, also gewöhnlichen async-Funktionen, die außerhalb der Sandbox laufen
Das mentale Modell: Workflow-Code ist ein Koordinator, der Activities aufruft, auf Timer oder Signals wartet und Entscheidungen auf Grundlage von Aktivitätsergebnissen trifft.
Workflows in TypeScript modellieren
Das Temporal TypeScript SDK erlaubt es dir, Workflows als normale async-Funktionen zu schreiben.
import * as workflow from "@temporalio/workflow";
import type { OrderActivities } from "./activities";
const { chargePayment, reserveInventory, sendConfirmationEmail, refundPayment } =
workflow.proxyActivities<OrderActivities>({
startToCloseTimeout: "30 seconds",
retry: {
maximumAttempts: 3,
nonRetryableErrorTypes: ["PaymentDeclinedError"],
},
});
export async function processOrderWorkflow(orderId: string): Promise<string> {
let paymentCharged = false;
try {
await chargePayment(orderId);
paymentCharged = true;
await reserveInventory(orderId);
await sendConfirmationEmail(orderId);
return "fulfilled";
} catch (err) {
// Compensate only if payment already completed
if (paymentCharged) {
await refundPayment(orderId);
}
throw err;
}
}Activities leben in einem separaten Modul und haben keine Einschränkungen — sie können auf die Datenbank zugreifen, APIs aufrufen oder Dateien schreiben:
// activities.ts — ordinary async functions, no sandbox constraints
export const orderActivities = {
async chargePayment(orderId: string): Promise<void> {
const order = await db.orders.findOrThrow(orderId);
await stripe.paymentIntents.capture(order.paymentIntentId);
await db.orders.update(orderId, { chargedAt: new Date() });
},
async reserveInventory(orderId: string): Promise<void> {
const items = await db.orderItems.findAll({ orderId });
await inventoryService.reserveBatch(items);
},
};
export type OrderActivities = typeof orderActivities;Die Trennung wirkt zeremoniell, bis du Activities in Tests mocken oder Implementierungen austauschen musst, ohne die Workflow-Logik anzufassen.
Signals und Queries: Interaktion mit laufenden Workflows
Langlaufende Workflows brauchen oft externe Eingaben mitten in der Ausführung — zum Beispiel auf eine menschliche Freigabe, einen Webhook-Callback oder eine Zahlungsbestätigung eines Drittanbieters zu warten.
import * as workflow from "@temporalio/workflow";
// Define signal and query handlers
const approveSignal = workflow.defineSignal<[{ approvedBy: string }]>("approve");
const rejectSignal = workflow.defineSignal<[{ reason: string }]>("reject");
const statusQuery = workflow.defineQuery<string>("status");
export async function expenseApprovalWorkflow(expenseId: string): Promise<void> {
let status = "pending";
let approvedBy: string | null = null;
let rejectionReason: string | null = null;
workflow.setHandler(approveSignal, ({ approvedBy: by }) => {
status = "approved";
approvedBy = by;
});
workflow.setHandler(rejectSignal, ({ reason }) => {
status = "rejected";
rejectionReason = reason;
});
workflow.setHandler(statusQuery, () => status);
// Block until approved or rejected, or timeout after 7 days
await workflow.condition(() => status !== "pending", "7 days");
if (status === "approved" && approvedBy) {
await processApprovedExpense({ expenseId, approvedBy });
} else {
await notifyRejection({ expenseId, reason: rejectionReason ?? "Timed out" });
}
}Signals sind Fire-and-Forget-Nachrichten an einen laufenden Workflow. Queries geben den aktuellen Zustand zurück, ohne den Workflow voranzutreiben. Beide werden aus dem Anwendungscode über den Temporal-Client gesendet — kein Polling, keine separate Status-Tabelle.
workflow.condition() blockiert einen Workflow, bis ein externes Ereignis eintrifft. Intern ist es nur ein Timer + Bedingungsprüfung, die aus der Historie wiedergegeben wird. Kein Thread wird tatsächlich blockiert.
Fehlerbehandlung und Kompensation
Durable Execution beseitigt Fehler in verteilten Systemen nicht — sie gibt dir aber Werkzeuge, um sie sauber zu behandeln. Temporal wiederholt Activities bei transienten Fehlern automatisch. Bei nicht-transienten Fehlern lässt sich das Saga-Pattern natürlich auf Workflow-Code abbilden.
export async function provisionTenantWorkflow(tenantId: string): Promise<void> {
const provisioned: string[] = [];
try {
await createDatabase(tenantId);
provisioned.push("database");
await createStorageBucket(tenantId);
provisioned.push("bucket");
await deployAppInstance(tenantId);
provisioned.push("app");
await sendWelcomeEmail(tenantId);
} catch (err) {
// Compensate in reverse order
const rollbacks = provisioned.reverse().map((resource) => {
if (resource === "app") return teardownAppInstance(tenantId);
if (resource === "bucket") return deleteStorageBucket(tenantId);
if (resource === "database") return dropDatabase(tenantId);
});
// Activities can also fail — Temporal retries them independently
await Promise.allSettled(rollbacks);
throw err;
}
}Vergleiche das damit, dieselbe Kompensationslogik mit einer Datenbank-Zustandsmaschine zu implementieren. Jeder Übergang, jeder Rollback-Pfad, jeder Retry braucht ein Zeilen-Update. Die Workflow-Version liest sich wie der Happy Path mit expliziter Fehlerbehandlung — und das ist genau das, was sie ist.
Wann Durable Execution nicht verwenden
Durable Execution hat echte Kosten. Der Temporal-Server ist ein weiteres Stück Infrastruktur, das betrieben werden muss. Jedes Aktivitätsergebnis wird serialisiert und gespeichert — große Payloads oder hochdurchsatzstarke Workflows können den History-Store belasten.
| Anwendungsfall | Empfehlung |
|---|---|
| Ein-Schritt-Background-Job | BullMQ oder native Queue ist einfacher |
| Latenzanforderungen im Sub-Sekunden-Bereich | Workflow-Overhead addiert mindestens einige Dutzend ms |
| Stateless Fan-out (Batch-Exports) | Besser über eine Job-Queue mappen |
| Mehrstufiger, mehrtägiger Human-in-the-Loop-Flow | Sehr gut geeignet |
| Verteilte Saga mit Kompensationslogik | Sehr gut geeignet |
| Prozesse, die auf externe Callbacks warten | Sehr gut geeignet |
Allein das Signal-Pattern rechtfertigt Temporal für jeden Flow, der wartend auf einen Webhook geparkt ist. Eine Status-Spalte zu pollen ist ein gelöstes Problem, das unnötige Datenbanklast erzeugt und trotzdem sorgfältiges Timeout-Handling erfordert.
Wichtige Erkenntnisse
- Durable Execution ist nicht nur Retry-Logik — sie ist wiedergegebene Event-History, die den Workflow-Zustand über Abstürze und Deployments hinweg rekonstruiert, ohne bereits abgeschlossene Schritte erneut auszuführen.
- Workflow-Code ist ein Koordinator, kein Executor — jegliches I/O passiert in Activities; Workflow-Funktionen müssen deterministisch sein.
- Signals und Queries ersetzen Status-Polling — sende ein Signal, um einen wartenden Workflow freizugeben, frage ihn nach dem aktuellen Zustand ab, ohne die Datenbank zu berühren.
- Kompensationslogik liest sich wie Code, nicht wie Migrationen — Saga-Patterns lassen sich direkt auf try/catch-Blöcke in Workflow-Funktionen abbilden.
- Vor der Einführung die Eignung prüfen — einfache Background-Jobs, Latenzanforderungen im Sub-Sekunden-Bereich und Stateless Fan-out werden von traditionellen Queues besser bedient.
- Die Infrastrukturkosten sind real — Temporal (oder ein verwaltetes Äquivalent wie Temporal Cloud) ist der richtige Trade-off für Workflows, die Stunden oder Tage dauern, nicht für jede async-Aufgabe.


