Durable Execution With Temporal — Replacing Fragile Job Queues and Cron Jobs
Advertisement
Introduction
Job queues are fragile. Cron jobs fail silently. Message queues lose data on network partition. Temporal solves this with durable execution: your workflow survives any failure by replaying its history, activities retry automatically with exponential backoff, and human-in-the-loop workflows can pause for weeks waiting for a signal. This post covers where Temporal wins and when simpler solutions are enough.
What Durable Execution Actually Means
Durability means a computation can resume from where it left off after any failure — including process crashes, network partitions, and deployment restarts. Traditional queues do not provide this.
With a standard queue like BullMQ, if a worker crashes after step 2 of a 4-step job, the job is either lost or restarted from the beginning. Partial state is gone. You write compensating logic manually.
With Temporal, the workflow engine persists every step decision in an event log. On restart, Temporal replays the log up to the last successful step and resumes execution from there. Step 2 is not re-executed — only step 3 and beyond. No data loss. No manual recovery.
// BullMQ: crash after step 2 loses partial state
async function processPayment(data) {
const order = await db.getOrder(data.orderId) // Step 1
const charge = await stripe.charge(order) // Step 2 — crash here
await db.updateOrder(order.id, 'charged') // Step 3 — never runs
await email.send(order.email) // Step 4 — never runs
}
// Temporal: crash after step 2, resumes at step 3
async function paymentWorkflow(orderId) {
const order = await activities.fetchOrder(orderId) // Step 1 — replayed
const charge = await activities.chargeCard(order) // Step 2 — replayed
await activities.updateOrder(order.id) // Step 3 — resumes here
await activities.sendEmail(order.email) // Step 4 — continues
}Workflows vs Activities: The Core Model
Temporal separates orchestration (workflow) from execution (activity). This is the most important concept to understand before writing any code.
A workflow is purely deterministic orchestration logic. It cannot perform I/O directly. It cannot use Math.random() or Date.now(). The same inputs must always produce the same sequence of activity calls, because the workflow is replayed from its history on every restart.
An activity is where side effects happen. Database writes, HTTP calls, file operations. Activities can fail and be retried independently. They execute once per attempt — not replayed.
// Workflow: pure orchestration, must be deterministic
async function orderWorkflow(orderId, amount) {
const order = await activities.fetchOrder(orderId)
if (!order) {
return { success: false, reason: 'order_not_found' }
}
try {
const charge = await activities.chargeCard(order, amount)
if (!charge.success) {
await activities.sendFailureEmail(order.email)
return { success: false, reason: 'charge_failed' }
}
await activities.updateOrderStatus(orderId, 'charged')
await activities.sendSuccessEmail(order.email)
return { success: true, chargeId: charge.id }
} catch (error) {
await activities.logError(error.message)
throw error
}
}
// Activities: real side effects, independently retryable
const activities = {
async fetchOrder(orderId) {
return db.query('SELECT * FROM orders WHERE id = ?', [orderId])
},
async chargeCard(order, amount) {
try {
return await stripe.charges.create({ amount, customer: order.customerId })
} catch (error) {
if (error.code === 'card_declined') {
// Declare non-retryable — Temporal will not retry this
throw new NonRetryableError('Card declined')
}
throw error // Network error — Temporal retries automatically
}
},
}Automatic Retry Policies
Temporal handles retries without you writing retry loops. Declare the policy once, apply it to any activity.
const retryPolicy = {
initialInterval: '1s',
maximumInterval: '10m',
backoffCoefficient: 2.0,
maximumAttempts: 10,
nonRetryableErrorTypes: ['NonRetryableError', 'CardDeclined'],
}
// Retry schedule for 10 attempts:
// Attempt 1: immediate
// Attempt 2: wait 1s
// Attempt 3: wait 2s
// Attempt 4: wait 4s
// Attempt 5: wait 8s
// Attempt 6: wait 16s
// Attempts 7-10: wait 10m (capped)
async function orderWorkflow(orderId) {
const order = await executeActivity(activities.fetchOrder, orderId, { retry: retryPolicy })
const charge = await executeActivity(activities.chargeCard, order, { retry: retryPolicy })
await executeActivity(activities.updateStatus, orderId, 'charged', { retry: retryPolicy })
}Long-Running Workflows With Signals
Temporal was designed for workflows that span days or weeks. A loan approval that requires human review, a document verification that takes 3 business days, a subscription renewal reminder sent 30 days before expiry.
// Workflow state accessible to signals
const state = { approved: undefined, cancelled: false }
// Signal handler: called by external systems
const signals = {
approve: (approved, reason) => {
state.approved = approved
state.approvalReason = reason
},
cancel: () => {
state.cancelled = true
},
}
async function loanApprovalWorkflow(applicationId, amount) {
if (amount < 5000) {
await activities.approveLoan(applicationId)
return { approved: true, reason: 'auto_approved' }
}
// Wait up to 14 days for manual review
const reviewReceived = await condition(() => state.approved !== undefined, '14d')
if (!reviewReceived || state.approved === false) {
await activities.denyLoan(applicationId)
return { approved: false, reason: state.approvalReason || 'timeout' }
}
await activities.verifyDocuments(applicationId)
await activities.approveLoan(applicationId)
await activities.disburseFunds(applicationId, amount)
return { approved: true }
}
// External system sends signal to resume the workflow
await client.signalWorkflow(workflowId, 'approve', [true, 'Documents verified'])The workflow can pause at the condition() call for 14 days. No timer, no polling, no cron. If the server restarts, the workflow resumes exactly where it left off.
Saga Pattern for Distributed Transactions
The saga pattern coordinates changes across multiple services with automatic rollback on failure. Temporal makes it straightforward.
async function bookingWorkflow(userId, flightId, hotelId, carId) {
const compensations = []
try {
const flight = await activities.reserveFlight(flightId, userId)
compensations.push(() => activities.cancelFlight(flight.reservationId))
const hotel = await activities.reserveHotel(hotelId, userId)
compensations.push(() => activities.cancelHotel(hotel.reservationId))
const car = await activities.reserveCar(carId, userId)
compensations.push(() => activities.cancelCar(car.reservationId))
await activities.chargeCard(userId, flight.price + hotel.price + car.price)
return { success: true, reservations: { flight, hotel, car } }
} catch (error) {
// Execute compensations in reverse order
for (let i = compensations.length - 1; i >= 0; i--) {
try {
await compensations[i]()
} catch (compensationError) {
await activities.logCompensationFailure(userId, compensationError.message)
}
}
throw error
}
}If car reservation fails, hotel and flight are automatically cancelled. If the server crashes mid-compensation, Temporal replays and continues the compensation sequence. No orphaned reservations.
Queries: Reading Workflow State
Queries let external systems read workflow state without modifying it.
const queries = {
getProgress: () => ({
state: state.paused ? 'paused' : 'running',
completedBatches: state.batchCount,
totalBatches: 100,
}),
}
// External system reads progress
const status = await client.queryWorkflow(workflowId, 'getProgress', [])
console.log(`Progress: ${status.completedBatches}/100`)Signals write to workflow state. Queries read from it. Together they make workflows fully interactive from external systems.
Temporal vs BullMQ vs Inngest
Use Temporal when: workflows run for days, involve multiple services, require saga/rollback, or must survive server restarts with zero data loss.
Use BullMQ when: jobs are short-lived (under an hour), logic is simple, and you are already running Redis.
Use Inngest when: you want managed infrastructure, are on serverless, and can accept vendor lock-in pricing.
| Metric | Temporal | BullMQ | Inngest |
|---|---|---|---|
| Long workflows | Yes (unlimited) | No (hours max) | Yes (unlimited) |
| Durability | Guaranteed | Unreliable | Guaranteed |
| Infrastructure | Self-hosted | Redis | Managed |
| Saga pattern | Built-in | Manual | Built-in |
| Human approval | Signals | Custom | Built-in |
| Learning curve | High | Low | Low |
| Cost at scale | Low | Medium | High |
Key Takeaways
- Temporal's durable execution model replays workflow history on restart, resuming exactly where it failed — no data loss, no manual recovery.
- Workflows must be deterministic: no I/O, no randomness, no current time. All side effects belong in activities.
- Activities are independently retryable with declarative policies including non-retryable error types.
- Signals allow external systems to send data into running workflows; queries allow reading workflow state without modification.
- The saga pattern with compensation functions handles distributed transactions and automatically rolls back partial work on failure.
- Long-running workflows can pause for days or weeks at
condition()calls waiting for signals, with no server resources consumed while waiting. - Use Temporal for complex, multi-step, mission-critical workflows; BullMQ for simple short-lived background jobs; Inngest for serverless with managed infrastructure.
- Deploy Temporal with at least 3 server replicas backed by PostgreSQL or Cassandra for production durability.
Advertisement