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 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 needing a compensating transaction — buy the durable engine: Temporal will save you a year of writing your own state machine badly, and that is a genuinely good reason to adopt one.

RequirementPlain hosted queueDurable execution
Fire something on a scheduleCron trigger, free to defineBuilt in
Retry a failed jobAutomatic; max_retries sets the budgetConfigurable policies
Dead-letter the poison onesA companion queue, on by defaultUsually a failure handler
Resume mid-workflow after a crashYour status columnThe entire point
Wait 30 days for a human signalA due-time column plus a cron sweepFirst-class waitForEvent
Version a workflow while instances are in flightNot applicableHandled, and genuinely hard
Learning costAn HTTP APIA determinism model your code must obey

The three-need setup, end to end

Create the failure queue first, then the working queue that points at it. dead_letter_queue takes the name of an existing queue and max_retries is the nack budget before a message crosses over:

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-dead","type":"standard"}'
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","dead_letter_queue":"ops-tasks-dead","max_retries":3}'

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 and give every publish a message_group_id and a deduplication_id — a FIFO queue that gets a standard-shaped message answers with QUEUE_FIFO_CONFLICT, which is the API telling you to pick one model and stay in it.

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","payload":{"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, payload: { 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, message_id: 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. Ack takes the message_id you were handed; a message id that isn’t in flight comes back as a 4xx, so a double ack is loud rather than silent.

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 fourth need is usually already on the key

The argument in the meeting is framed as a two-way choice, and the thing that decides it in six months is normally none of the above: the schedule that fires these jobs (POST /v1/cron/create), the error record when one of them dies for good (POST /v1/errors/capture), and the mail that tells a human about it (POST /v1/email/send) all run on the same account and the same key as the queue. No second vendor, no second SDK, no second onboarding.

The practical consequence is billing rather than architecture. It’s one bill at the end of the month and one usage view, so per-tenant cost attribution is a query against GET /v1/account/usage instead of a reconciliation across three invoices with three different billing periods.

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-27) with $2 of free credit on a new account, while consume, ack, stats, create and dead-letter reads are free — so an idle service costs nothing at all. Rates here have moved downward over time, 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.

There’s also a third answer nobody in the meeting proposed: you may already own two of the three needs. If your stack already runs a queue with a redrive policy, or a Redis-backed job library your app depends on anyway, you have retries and a dead-letter lane today and need only a scheduler — and adopting a whole platform to get one of three things is how stacks quietly grow.

Limitations you’re accepting

The retry budget is a count, not a curve. max_retries decides how many nacks a message survives before it’s dead-lettered, and the catch is that there’s no per-attempt backoff setting — if you want exponential backoff, republish the payload yourself with a delay_seconds you calculate.

That delay tops out at 604800 seconds, seven days. A workflow that waits 30 days for a customer to click something needs a due-time column and a cron sweep instead, which is exactly the kind of bookkeeping a durable engine hides from you.

And there’s no workflow visualisation, because there’s no workflow object. What you get is a queue depth, a dead-letter count and your own status column — three things you have to assemble into a mental model that the other option draws for you.

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.

References

Browse more queue developer guides