Your workflow engine can't tell you what it's about to do
Temporal, Inngest, Trigger.dev and DBOS all survive a crash — by three different mechanisms, each charging a different tax. None of them can tell you what a workflow contains before it runs. A declarative DAG can, and that one property is the whole argument.
Ask Temporal, Inngest, Trigger.dev or DBOS a simple question about a workflow you haven’t run yet: what steps does it contain?
None of them can answer. Not because of an API gap — because of the model. In all four, a workflow is a function, and its steps are whatever that function turns out to call. The graph is not an input to the system; it’s a shadow the execution casts. You can see it afterwards, in a trace. Before the first step runs, it does not exist anywhere.
They differ enormously in how they survive a crash — replay, memoized re-entry, and process checkpointing are three genuinely different mechanisms, and I’ll separate them below, because most comparisons don’t. But that variation is orthogonal to the question above. All four answer it the same way: they can’t.
For most pipelines that’s a fine trade. But it is a trade, and it’s worth being precise about both sides — because the other model is missing from the conversation in one specific place.
Not from the industry at large: Airflow has been a declarative DAG for over a decade, and Step Functions runs declarative state machines at a scale none of these approach. It’s missing from TypeScript application backends, where “durable execution” has come to mean “write a function, we’ll make it survive” — and where, if that’s the only model you’ve been shown, you’d be forgiven for thinking it was the only one there is.
Two ways to express a workflow
Imperative durable execution. You write ordinary-looking code and call steps. The engine makes those calls survive a crash. How it does that varies more than the marketing suggests — we’ll get to that — but the workflow itself is a function, and its structure is whatever the function happened to do.
Declarative DAGs. You declare steps and their dependencies as data. The engine reads that structure, runs each step when its dependencies complete, persists the transition, and resumes from persisted state. The workflow is a value before it is an execution.
I maintain octaflow, which takes the second path. This isn’t a pitch for it; it’s an argument that the second path has properties the first structurally cannot have, and vice versa, and that most teams pick without knowing there was a choice.
The graph is a value
Here is a diamond in octaflow — two branches over one root, fanning back in:
import { z } from 'zod';
import { defineStep, buildWorkflow } from 'octaflow';
const inputSchema = z.object({ id: z.string() });
const fetchRecord = defineStep({
type: 'fetchRecord',
workflowInputSchema: inputSchema,
outputSchema: z.object({ raw: z.string() }),
handler: async (ctx) => ({ raw: `record:${ctx.workflowInput.id}` }),
});
const enrichA = defineStep({
type: 'enrichA',
workflowInputSchema: inputSchema,
outputSchema: z.object({ a: z.string() }),
dependencies: { fetchRecord },
handler: async (ctx) => ({ a: `${ctx.deps.fetchRecord.raw}+A` }),
});
const enrichB = defineStep({
type: 'enrichB',
workflowInputSchema: inputSchema,
outputSchema: z.object({ b: z.string() }),
dependencies: { fetchRecord },
handler: async (ctx) => ({ b: `${ctx.deps.fetchRecord.raw}+B` }),
});
const merge = defineStep({
type: 'merge',
workflowInputSchema: inputSchema,
outputSchema: z.object({ merged: z.string() }),
dependencies: { enrichA, enrichB },
handler: async (ctx) => ({ merged: `${ctx.deps.enrichA.a} & ${ctx.deps.enrichB.b}` }),
});
const wf = buildWorkflow({ type: 'diamond', inputSchema,
steps: { fetchRecord, enrichA, enrichB, merge } });
enrichA and enrichB run concurrently and merge waits for both. Nobody
scheduled that. It follows from the dependencies field — which is a plain
object you can read:
for (const s of wf.definition.steps) {
console.log(s.key, '→', s.dependencies?.join(', ') ?? '—');
}
That loop is the entire argument. It runs without a database, without starting anything, without a single handler having executed. The structure is available.
Four things fall out of that, and none are available to an engine whose graph is a trace:
You can render the pipeline before it starts. A progress UI that shows all four steps greyed out and then lights them up needs the list of steps up front. With a trace-based engine your UI can only show what has already happened — steps pop into existence as they run. That’s a materially worse experience, and it isn’t a UI bug, it’s the model.
You can validate in CI. Cycles, unreachable steps, a dependency on a step that was never registered, an output schema that doesn’t match what a downstream step reads — all statically checkable, because it’s all data. In the imperative model those are runtime discoveries.
Fan-in is native. Not against the four engines above — they all express a
join with Promise.all — but against the thing many teams reach for first.
BullMQ’s flows are trees: a job has one parent, so two branches cannot
converge on a shared child. If your
pipeline has a diamond in it — and enrichment pipelines are nothing but diamonds
— you either restructure the work or hand-roll the join. A DAG has no such
restriction, because a dependency list is a list.
Dependency wiring is type wiring. ctx.deps.fetchRecord.raw is typed from
fetchRecord’s outputSchema. Add a step and its consumers see the new shape;
break an output and the compiler names them. That one isn’t exclusive to
declarative engines — imperative step functions get types from being functions —
but here you get it without giving up inspectability.
Four models, four taxes
Now the part practitioners feel most, and that rarely survives contact with a comparison table — because “durable execution” names an outcome, not a mechanism, and the mechanisms differ.
Sort the field on a question that actually discriminates: on recovery, is your code re-entered?
Temporal — replay. Every effect goes into an event history. On recovery the workflow function re-executes from the top, and calls that already have recorded results return them instead of re-doing the work.
Inngest and DBOS — re-entry with completed steps skipped. Lighter, same
shape: the function body runs again, but step.run(...) / @DBOS.step()
results come back from the store rather than re-executing. DBOS states the
consequence plainly: “The workflow function must be deterministic: if executed
multiple times, with the same arguments and step return values, the workflow
should invoke the same steps with the same inputs in the same order.”
All three collect the determinism tax — though they charge it in different places, and this is where comparisons usually go wrong.
Inngest and DBOS run your function body in ordinary Node, so the rule lands on
you: anything non-deterministic has to move into a step. DBOS’s docs list the
usual suspects — “generating a random number, or getting the local time.” Call
Date.now() in the function body and you have a bug that only appears after a
crash.
Temporal makes the sandbox deterministic for you. Its TypeScript SDK replaces
Date, Date.now and Math.random with versions tied to the activation
timestamp and a seeded PRNG, and ships a uuid4() documented as “safe for use
within a workflow.” So the popular version of this criticism — “you can’t call
Date.now()” — is simply false, and worth retiring.
The tax is real, it just sits further up. Workflow code runs in a sandbox: no
I/O, no arbitrary imports, and WeakRef throws outright because GC isn’t
deterministic. Changing a workflow while runs are in flight needs versioning
APIs. All of it works, and Temporal’s tooling for it is the best in the field.
It is still a set of rules invisible in the code itself, which every new team
member pays for once.
Trigger.dev — checkpoint and restore. A genuinely different mechanism, and
it deserves separating out. When a run hits a wait or triggers a subtask,
Trigger.dev uses CRIU to capture “the task’s memory, CPU registers, and open
file descriptors”, releases the resources, and later restores that snapshot
into a new environment to continue “exactly where it left off.” Nothing
re-executes, so there is no determinism tax. Date.now() is fine. Their pitch —
plain async code — is accurate, and any comparison that lumps them in with
replay engines is wrong.
Two things their docs are honest about that summaries never are. Waits under 60 seconds don’t checkpoint at all, so the tax is only charged on long ones. And it isn’t purely snapshotting: a subtask’s output can be “cached based on its idempotency key” — opt-in, passed per call — so there is memoization underneath the checkpoints. Hybrid, not pure.
It isn’t free either; the tax moves. A snapshot captures your process, not the
world outside it. Anything live across a wait — a socket, a database connection,
a browser — has to be torn down and rebuilt, which is why the API has
onWait / onResume hooks, and why their own Playwright guide closes the
browser on wait and relaunches it on resume. Trade the determinism tax for a
resource tax.
Declarative DAGs — nothing is re-entered. A step runs, its output is
persisted, the engine moves on. After a crash, completed steps are read back from
the store; they never re-execute. Nor does any enclosing function, because there
isn’t one — the dependencies field holds the structure a workflow function
would otherwise have held. So a handler is an ordinary async function, with no
sandbox around it and no replay to stay deterministic for.
The tax here is the one I’ll concede at length in a moment: the shape is fixed at definition time. An expressiveness tax.
The tax everyone pays
One charge is common to all four, and it is missing from most comparisons including earlier drafts of this one. Step handlers must be idempotent. Delivery is at-least-once, so a step can run twice — and a step whose worker died mid-run is retried by every engine here, octaflow included.
DBOS says it plainly: “Steps should be idempotent, meaning it should be safe to retry them multiple times. If a workflow fails while executing a step, it retries the step during recovery.” Temporal gives activities the same guidance, framed as a best practice rather than a hard requirement — but at-least-once execution collects either way.
So “no determinism tax” is a claim about orchestration code, not about your handlers. Generating a UUID inside a step and writing it downstream is exactly what breaks on a retry, in every model on this page. The differences above are real; they sit one level up.
The 2×2 that falls out
| Code re-entered on recovery | Not re-entered | |
|---|---|---|
| Imperative | Temporal, Inngest, DBOS — determinism tax | Trigger.dev — resource tax |
| Declarative | — | octaflow — expressiveness tax |
The empty cell is no accident, and not quite a discovery either — it’s definitional, which is the point. Re-entering your code is how an imperative engine rediscovers what the workflow was going to do next. If the graph is already data there is nothing to rediscover, so there is no function to re-enter and no cell to fill. The axes aren’t independent; the second falls out of the first.
Which is “the graph is a value” again, arrived at from the other side.
One clarification, because the word collides: Trigger.dev does expose
ctx.run.isReplay. That’s for manually re-running a run — from the dashboard, possibly against updated code — not for crash recovery. Different feature, same noun.
So: four ways to pay. Now the one I’m asking you to pay.
What declarative genuinely can’t do
A static DAG fixes the set of steps at definition time. The sharpest way I can put the boundary: it can pick a branch, it can’t invent a step. If your process is “loop until a human approves, branching on whatever they typed”, an imperative durable function will express it better and you should use one. That isn’t a limitation to work around; it’s the wrong tool.
The declarative model softens the edge in four places rather than pretending the problem away:
- Conditional branching. A
whenguard decides whether a step runs at all; a step whose guard says no is skipped, and so is everything reachable only through it. Ajoin: 'any'step is where the arms converge again, so the branch that wasn’t taken doesn’t skip the join along with it. If/else, over a graph you can still read before it runs. - Runtime-sized fan-out.
defineMapSteptakes anitems(ctx)function that derives a list from upstream output and spawns one child step per item, each with its own retry budget and concurrency gate. The shape is static (“map over whatever comes back”); the width is discovered at run time. - Sub-workflows. A step can start a child workflow and await its result — how you get a second graph chosen by data from the first.
- Signals, with a deadline.
waitForEventsuspends a step until something external resumes it, and atimeoutMsdecides what happens if nothing does: fail the run, or complete the step with a stand-in answer and let a guard above route on it. “Approve within 48 hours, otherwise escalate” is a DAG.
Between them, those cover most of what looks dynamic at first glance. They do not cover unbounded loops with data-dependent branching, and no amount of API design will — that’s the expressiveness tax, and it’s the honest one.
You also give up the dashboard. octaflow ships lifecycle events and a wire-safe projection of a run; it does not ship a UI. Temporal, Inngest and Trigger.dev all do, and theirs are good. Wanting observability out of the box rather than an observer interface and an afternoon is a legitimate reason to choose differently.
And polyglot workers. The DAG and its schemas are TypeScript values. Temporal’s SDKs span Go, Java, Python, .NET and more. If your steps aren’t all in one language, this model doesn’t apply to you at all.
The infrastructure question, briefly
A second axis gets conflated with the first: what you have to operate. Temporal is a cluster — frontend, history, matching and worker services, plus a database. Inngest self-hosts its server — against Postgres and Redis in the recommended production setup (the Dev Server is the part that isn’t for production). Trigger.dev wants that same pair, plus ClickHouse once you have volume.
A declarative DAG can be a library instead. octaflow’s production store is Postgres and its queue is pg-boss, which is also Postgres — so if you already run one, the engine adds no infrastructure and there is no server to operate at all.
That’s a property of packaging rather than of the model, and DBOS proves it from the other side: an imperative durable-execution library over Postgres, with nothing to run. The two properties do tend to travel together — replay engines have historically wanted a service to hold the journal and match tasks — but they are separable, and worth keeping apart when you compare.
The honest cost: a Redis-backed queue beats a Postgres-backed one on raw throughput outright, because it isn’t writing a durable transition per step to a relational database. That write is the feature. In any real workflow, handler time dwarfs the engine overhead — 1–3 ms per transition at low concurrency, climbing with parallelism — so the practical question is whether a few milliseconds per transition matter next to what your steps actually do.
How to choose
Not a scoring matrix — two questions.
Is the shape of your work known before it runs? Ingest → enrich → summarize → publish is known. “Loop until the reviewer is satisfied” is not. If it’s known, a declarative DAG gives you inspectability, CI validation and real fan-in. If it isn’t, go imperative — and then pick which tax you’d rather pay: determinism rules with Temporal’s maturity behind them, or Trigger.dev’s resource re-establishment across checkpoints.
Do you want to operate a workflow system? If not, and you already run Postgres, a library is available. If you want the dashboards, the polyglot SDKs and the operational maturity a decade of production has bought — buy them. That maturity is real, and I’m not going to pretend a pre-1.0 library substitutes for it.
What I’d push back on is the framing where durable execution means imperative replay. It doesn’t. It means your process survives a crash. There is more than one way to arrange that, and one of them lets you ask the engine what it’s about to do.
octaflow is MIT, pre-1.0, and was extracted from a production multi-tenant booking platform where it runs the AI content pipelines. Benchmarks, a comparison table and an honest “when not to use this” list are in the README. Corrections about the other engines’ current behaviour are welcome — they all move fast.