FIFO or standard queue? Duplicate handling for a small SaaS

Ordering and exactly-once are different promises. Why a standard queue plus an idempotent consumer beats FIFO for most small teams, with the SQL and the worker code.

Short version: pick standard, and make the consumer idempotent. FIFO buys you ordering inside a message group, and people reach for it hoping to buy exactly-once delivery, which is not the same promise and is not what they get. Infrai’s queue exposes both types at creation time — standard and fifo — and on the day we tested, only one of them could actually be published to.

That last sentence matters more than the theory, so here it is up front.

What each type promises

StandardFIFO
Deliveryat-least-onceat-least-once, ordered per group
Orderingbest effort, no guaranteestrict within a message group
Duplicatesexpected, you handle themreduced, still possible
NaminganythingInfrai requires the name to end in .fifo
Throughputunconstrained by orderingserialized per group, so slower per key
Consumer complexityneeds an idempotency checkneeds an idempotency check anyway

The bottom row is the punchline. Even AWS, whose FIFO queues do far more work on this than most, documents deduplication as a five-minute window keyed on content or an explicit token — outside that window a redelivered message is a new message. Any consumer you’d trust with money has to be safe against replays regardless of queue type.

Where your duplicates really come from

Not from the broker being sloppy. From three ordinary situations:

The publisher times out on a slow network, retries, and both requests actually landed. Your worker processes a message, commits the database write, then crashes before acking — the lease expires and the message comes back. A colleague redrives the dead-letter queue after a fix and replays yesterday’s failures on top of jobs that partly succeeded.

None of those are solved by ordering. All three are solved by the same twenty lines of code.

The idempotency key goes in your database

Give every message a business-level key — not the message_id, which changes on republish, but something the work itself is identified by.

CREATE TABLE processed_jobs (
  idempotency_key text PRIMARY KEY,
  message_id      text NOT NULL,
  processed_at    timestamptz NOT NULL DEFAULT now()
);

Then the worker claims the key before doing the work, inside the same transaction as the effect:

import process from "node:process";
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const BASE = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is missing");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };

async function call(path, payload) {
  const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (out.ok === false) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
  return out.data;
}

export async function handleOnce(msg, doWork) {
  const idem = `${msg.payload.kind}:${msg.payload.order_id}`;
  const tx = await pool.connect();
  try {
    await tx.query("BEGIN");
    const claim = await tx.query(
      "INSERT INTO processed_jobs (idempotency_key, message_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING 1",
      [idem, msg.message_id],
    );
    if (claim.rowCount === 0) {
      await tx.query("ROLLBACK");
      console.log(`duplicate ${idem} on delivery ${msg.delivery_count}, skipping`);
      return "duplicate";
    }
    await doWork(msg.payload, tx);
    await tx.query("COMMIT");
    return "done";
  } catch (err) {
    await tx.query("ROLLBACK");
    throw err;
  } finally {
    tx.release();
  }
}

export async function pump(doWork) {
  const { items } = await call("/v1/queue/consume", { queue: "order-events", max_messages: 10 });
  for (const msg of items) {
    const outcome = await handleOnce(msg, doWork).catch((e) => { console.error(e.message); return "failed"; });
    if (outcome !== "failed") await call("/v1/queue/ack", { queue: "order-events", receipt_handle: msg.message_id });
  }
  return items.length;
}

Two things to notice. The claim and the effect share one transaction, so a crash between them rolls both back and the redelivery does the work properly. And a failed job isn’t acked at all — the lease expires, the message returns with delivery_count incremented, and after the third delivery it drops into the dead-letter queue on its own. In our testing on 2026-07-26 that was exactly three deliveries, whatever max_receive_count we asked for at create time.

One naming quirk: the consume response labels the handle message_id, while POST /v1/queue/ack takes it as receipt_handle. Same string, two names.

Ordering without a FIFO queue

If order genuinely matters — a status going pending → paid → refunded and never backwards — carry a version in the payload and let the consumer reject stale writes. UPDATE orders SET status = $1, version = $2 WHERE id = $3 AND version < $2 is a one-line guard that survives out-of-order delivery, parallel workers and replays. That’s cheaper than serializing an entire queue for the 2% of messages that care.

What happened when we tried FIFO

Creation works, with one rule the error message tells you plainly:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"order-events.fifo","type":"fifo","dlq":"order-events-dlq.fifo"}'

Drop the suffix and you get INVALID_ARGUMENT with “FIFO queue name must end with .fifo”. Fair enough. Publishing to it, though, came back like this every time:

{
  "ok": false,
  "error": {
    "code": "INVALID_ARGUMENT",
    "http_status": 400,
    "message": "queue.publish failed: queue 'order-events.fifo' already exists",
    "retryable": false
  }
}

That’s a publish-path bug, not a usage error — publish auto-creates a missing queue as standard, and the collision with the existing FIFO queue surfaces as a nonsense message. So the honest recommendation for now is a standard queue with the idempotency check above, and a note in your backlog to retest. Related failures show up as QUEUE_FIFO_CONFLICT.

A standard queue behaves exactly as documented:

curl -sS "https://api.infrai.cc/v1/queue/get/order-events" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The cheap part and the expensive part

Duplicate handling costs you a table and an index, not money. On the queue itself, only publishing is metered — $0.00002 per message, verified 2026-07-26 — while create, consume, ack and dead-letter reads are free and rate-limited. That pricing shape is the useful bit: retries and redeliveries of a duplicate-heavy workload don’t add to the bill.

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id == "queue.publish") | .billing'

Expect that number to fall rather than rise; discounts run and per-call rates have trended down. New accounts also carry $2 of credit, which is a lot of test messages.

When another broker is the right answer

SQS FIFO is the mature choice if you need ordering today and you’re already in AWS — content-based deduplication and message group IDs are built in, and you’d be better off using them than reimplementing them. Kafka is the answer when ordering per partition is the core of your architecture and you’re keeping a log, not draining a work queue. RabbitMQ suits you if you want routing topologies and per-consumer prefetch tuning more than you want a hosted HTTP API.

Infrai’s queue fits the small SaaS case: a standard queue, a dead-letter lane, an idempotent worker, and the same key already covering the email, storage and error tracking the job needs next. The trade-off is that FIFO isn’t usable yet, max_messages caps at 10 per consume call, and ordering is something you design around rather than buy.

References

Browse more queue developer guides