Is durable execution overkill when you only need retries and a DLQ?
A decision test for teams arguing between a durable-execution framework and a plain hosted queue, plus the three-need setup implemented end to end in Node 22.
If the requirements list really is scheduled triggers, retries, dead-letter queue, then yes — a durable-execution framework is more machinery than the job needs, and you can have all three from a hosted queue and a cron trigger without adopting a programming model. On Infrai that’s one create call, one publish per job, and a consume/ack loop; the dead-letter queue arrives with the queue and the retries are automatic.
That said, teams don’t usually argue about Inngest versus Trigger.dev for no reason. Somebody on the team is anticipating a fourth requirement they haven’t written down yet, and it’s worth finding out what it is before you settle the argument.
The question that actually decides it
Durable execution exists to solve one problem: a multi-step process that must survive the death of the machine running it and resume at step 4 without redoing steps 1 through 3. Everything else those platforms ship — the dashboards, the retry policies, the scheduling — is available elsewhere. Deterministic replay of a partially completed workflow is not.
So ask this, out loud, in the meeting.
Do we have a process where step 3 has an irreversible side effect, steps 4 and 5 can fail independently, and re-running from the top would double-charge somebody? If the answer is no, a queue plus a status column in your database does the same work with less to learn. If the answer is yes and it’s your core flow — payments, provisioning, anything with a compensating transaction — Temporal or one of the hosted equivalents will save you a year of writing your own state machine badly.
| Requirement | Plain hosted queue | Durable execution |
|---|---|---|
| Fire something on a schedule | Cron trigger, free to define | Built in |
| Retry a failed job | Automatic, 3 deliveries | Configurable policies |
| Dead-letter the poison ones | Automatic -dead queue | Usually a failure handler |
| Resume mid-workflow after a crash | Your state column | The entire point |
| Wait 30 days for a human signal | Your database plus a sweep | First-class waitForEvent |
| Version a workflow while instances are in flight | Not applicable | Handled, and genuinely hard |
| Learning cost | An HTTP API | A determinism model your code must obey |
The three-need setup, end to end
Create the queue and its dead-letter lane. The dlq field takes the failure queue’s name — a boolean gets you HTTP 501, which is a strange way to say “wrong type”:
curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"ops-tasks","type":"standard","dlq":"ops-tasks-dead"}'
Standard queues don’t guarantee ordering. If two messages for the same entity must be processed in sequence, create the queue with "type":"fifo" instead — mixing the two ideas on one queue produces QUEUE_FIFO_CONFLICT, which is the API telling you to pick.
Publishing is the only billed call in the design:
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"ops-tasks","body":{"step":"provision","account_id":"acct_9931"}}'
{
"ok": true,
"data": {
"message_id": "qmsg_7grgZi6PlN4Ty2iRmgHlQm0g",
"queue": "ops-tasks",
"payload": { "step": "provision", "account_id": "acct_9931" },
"status": "available",
"delivery_count": 0
}
}
Where the poor man’s workflow gets ugly
Here’s the same four-step process a durable framework would express as one function, written as messages. Each handler reads the current step from your own database, does one thing, records it, and publishes the next message. The pattern works, and the ugliness is real — it’s spread across four handlers and a status column instead of sitting in one readable function.
import process from "node:process";
const BASE = "https://api.infrai.cc";
const QUEUE = "ops-tasks";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is required");
const HEADERS = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
// Stand-in for your real store; in production this is a row with a UNIQUE key.
const completed = new Map();
async function post(path, payload) {
const res = await fetch(`${BASE}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(payload) });
const json = await res.json();
if (!json.ok) throw new Error(`${path}: ${json.error.code} ${json.error.message}`);
return json.data;
}
const NEXT = { provision: "seed", seed: "notify", notify: null };
async function runStep({ step, account_id }) {
const marker = `${account_id}:${step}`;
if (completed.has(marker)) return; // at-least-once delivery means this WILL happen
console.log("running", step, "for", account_id);
completed.set(marker, Date.now());
const next = NEXT[step];
if (next) await post("/v1/queue/publish", { queue: QUEUE, body: { step: next, account_id } });
}
for (;;) {
const { items } = await post("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
if (!items.length) { await new Promise((r) => setTimeout(r, 2000)); continue; }
for (const message of items) {
try {
await runStep(message.payload);
await post("/v1/queue/ack", { queue: QUEUE, receipt_handle: message.message_id });
} catch (err) {
console.error("step failed", message.message_id, message.delivery_count, err.message);
}
}
}
The completed check is not optional. Delivery is at least once, so every handler has to be idempotent — and that requirement doesn’t disappear if you buy a durable-execution platform, it just moves inside their activity boundary.
Watch the depth while you’re testing:
curl -sS "https://api.infrai.cc/v1/queue/stats/ops-tasks" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The costs on both sides
A queue-and-column design has no fixed monthly floor. Publishing is metered at $0.00002 per message (verified 2026-07-26) with $2 free credit on a new account, and consume, ack, stats and dead-letter reads are free, so an idle service costs nothing. Prices here have moved downward, so read the live figure rather than the sentence you just read:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id == "queue.publish") | .billing'
Against that, a durable-execution platform charges per step or per run and gives you a workflow UI, which for a team debugging a twelve-step process is worth real money. Self-hosting Temporal is free of licence cost and expensive in operator time.
There’s also a third answer nobody in the meeting proposed: you may already own two of the three needs. A team running SQS with a redrive policy, or BullMQ against a Redis it already pays for, has retries and a dead-letter lane today and needs only a scheduler — and adopting a new platform to get one of three things is how stacks grow.
Limitations you’re accepting
Three delivery attempts, fixed. max_receive_count is accepted at create and via update, and the queue keeps reporting 3 — worth flagging before you write a retry policy that assumes otherwise. Bulk dead-letter redrive doesn’t work today; replaying messages one at a time does. There’s no workflow visualisation, because there’s no workflow object — what you get is a queue depth and your own status column.
And there’s no per-queue region pin, so if the deciding factor is EU-only processing, neither this nor the argument you’re having is the real question.
If your list stays at three needs for another quarter, the queue was the right call. If a fourth need shows up that starts with “and then wait for the customer to…”, revisit it honestly — that’s the sentence durable execution was built for.