Every automation article sells durable workflows on retries. Retries are fine.
try/catch with a loop gets you most of the way there, and if that were the
whole pitch you could skip the dependency.
The feature worth paying for is narrower and much more useful: a step that already succeeded does not run again.
The problem with retrying the whole thing
Say a captured lead needs four things done to it:
enrich → score → store → text me
Naive retry wraps all four. The enrichment API times out on attempt one, so you retry. All four run again. Now consider what happens if the failure is at step four instead:
attempt 1: enrich ✓ score ✓ store ✓ text ✗
attempt 2: enrich ✓ score ✓ store ✓ text ✓
You paid for enrichment twice. You wrote the row twice. And if the notification had failed three more times, you would have four enrichment charges and a sloppy table — for one lead that, from the outside, arrived once.
The usual fix is to make everything idempotent. That is real engineering work, you have to do it for every step, and you have to keep doing it forever.
Checkpointing instead
With a step-based engine, each step's result is recorded when it completes:
export const handleLead = inngest.createFunction(
{ id: "handle-lead", triggers: [leadCaptured], retries: 4 },
async ({ event, step }) => {
const lead = await step.run("load", () => getLead(event.data.leadId));
const enrichment = await step.run("enrich", () => enrich(lead));
await step.run("mark-enriched", () =>
updateLead(lead.id, { status: "enriched", score: enrichment.score }),
);
await step.run("notify", () => sendWhatsApp(lead));
},
);Now the same failure looks like this:
attempt 1: load ✓ enrich ✓ mark ✓ notify ✗
attempt 2: load → enrich → mark → notify ✓
(replayed from cache, not re-executed)
The function body runs again — that is how it resumes — but step.run for a
completed step returns the stored result instead of calling your code. One
enrichment charge. One write. The retry is free.
The rule that follows from it
Everything with a side effect goes inside a step.run. Code between steps
executes on every replay.
// Wrong — runs on every attempt
await sendWhatsApp(lead);
await step.run("store", () => save(lead));
// Right
await step.run("notify", () => sendWhatsApp(lead));
await step.run("store", () => save(lead));The first version texts you four times for one lead and you will spend an afternoon working out why.
The corollary is a shape worth internalising: the HTTP handler should do almost nothing. Validate, persist, emit, return. All the slow, flaky, expensive work belongs in steps, where failure is survivable. Move a step's work back into the route to "simplify" and you have quietly given up the retry semantics.
What you get once it is running
Because each step is recorded, you also get things that are painful to build yourself:
- Sleeps that survive deploys.
step.sleep("wait", "1h")is not a timer held in memory. Ship new code during that hour; the run still wakes up. - A real execution history. Which step failed, with what error, on which attempt, with the payload. Not a log line you hope you wrote.
- Concurrency and throttling per key, so one customer's burst cannot exhaust a rate limit shared with everyone else.
When you do not need this
If the work is one call and losing it costs nothing, a try/catch is the
right amount of machinery. Reach for a workflow engine when you have multiple
side effects in sequence, at least one of which talks to something you do not
control.
That is the actual line. Not "is this important" — importance argues for a database, which is a different tool. It is "does this touch several systems in order, where the middle one can fail." That is the shape durable execution was built for, and it is a surprisingly common shape once you start noticing it.