Push subscription or polling consumer: which one retries failed jobs better

Measured delivery latency, retry spacing and failure modes for Infrai's HTTPS push subscription against a polling consumer — the numbers that decide which your processor wants.

Both shapes are available on the same Infrai queue, and the choice is not about taste. Push hands each message to a public HTTPS endpoint you register once, retries three times about six seconds apart, then dead-letters it. Polling hands your worker up to ten messages per call and gives it a 300-second lease, so retry spacing and concurrency are yours to set. If your processor is a serverless function, push. If it has to respect a downstream rate limit, poll.

The rest of this page is the measurements behind that, taken on 2026-07-26.

The two loops, side by side

Push subscriptionPolling consumer
Registrationone call, then nothing to runa process you keep alive
Reachabilityneeds a public HTTPS URLworks behind NAT, in Docker, on a laptop
Delivery latency~5 s after publish in our testingyour poll interval, plus ~50 ms server time
Batch sizeone message per POST in our testingup to 10 per POST /v1/queue/consume
Concurrencyfixed at 10 by the subscriptionwhatever your loop does
AckHTTP 2xx from your endpointexplicit POST /v1/queue/ack
Retry on failure3 attempts ≈6 s apart, then DLQlease expiry, max_receive_count 3, spacing = visibility timeout
Backpressurenone — it keeps arrivingnatural: you stop calling consume
Consumer-side costzero API callsone free consume per poll

What push actually sends

Registration is a single call. The queue name appears in both the path and the body:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/push_subscribe/jobs-push" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"jobs-push","url":"https://worker.example.com/hooks/jobs"}'
{
  "ok": true,
  "data": {
    "subscription_id": "sub_NfWZ2WqmTmaUfZ86AZV3HMjk",
    "queue": "jobs-push",
    "concurrency": 10,
    "max_retries": 3,
    "active": true,
    "started_at": "2026-07-26T00:37:18.340328Z"
  }
}

concurrency and max_retries come back as fixed values — the documented call takes a queue and a URL, so treat 10 in flight and 3 attempts as the contract rather than as defaults you can tune.

What lands on your endpoint is a POST carrying an envelope, two identifying headers (X-Infrai-Subscription-Id and X-Infrai-Queue), and no signature:

{
  "messages": [
    {
      "message_id": "qmsg_j8pSyBmgsKZRI0Y7zr9CMHw3",
      "queue": "jobs-push",
      "payload": { "kind": "reindex", "tenant": "acme" },
      "headers": null,
      "published_at": "2026-07-26T00:37:18.767586Z",
      "delivery_count": 1
    }
  ]
}

The messages array held exactly one message in every delivery we observed, even when five were published back to back — they arrived as five separate POSTs within 30 seconds. Write the handler as a loop over the array anyway; that’s the shape the envelope promises.

Return 2xx and the message is gone from the queue. Return anything else and it comes back twice more at roughly six-second intervals — we saw attempts at t+0, t+6 s and t+12 s against an endpoint hard-wired to 500 — after which dlq_count on the queue went to 1. That’s the whole retry policy: no exponential curve, no per-subscription tuning.

Six seconds between attempts is generous for a redeploy and useless for a downstream that’s rate-limiting you. If your processor calls an API with a 5 rps budget, push will hand you 10 concurrent messages and then throw them away three failures later.

The subscriber implementation itself — verifying the caller, answering fast, staying idempotent — is a separate job, and we wrote it up at the push subscriber guide.

What polling actually does

Consume is a POST with a queue and a batch size, and it answers immediately whether or not anything is waiting:

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"jobs-poll","max_messages":10}'

There’s no long-polling parameter, so an empty queue returns {"items": [], "next_cursor": null} in about 50 ms of server time. Your loop supplies the wait, and the sleep you choose is the real latency knob:

import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";

const BASE = "https://api.infrai.cc";
const QUEUE = "jobs-poll";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };

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

let idle = 0;
let running = true;
process.on("SIGTERM", () => { running = false; });

export async function poll(handle, ratePerSecond = 5) {
  while (running) {
    const { items } = await call("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
    if (!items.length) {
      idle = Math.min(idle + 1, 5);
      await sleep(500 * 2 ** idle); // 1 s, 2 s, 4 s, 8 s, 16 s ceiling
      continue;
    }
    idle = 0;
    for (const message of items) {
      const started = Date.now();
      try {
        await handle(message.payload, message.delivery_count);
        await call("/v1/queue/ack", { queue: QUEUE, receipt_handle: message.message_id });
      } catch (err) {
        console.error(`leaving ${message.message_id} unacked: ${err.message}`);
      }
      const spacing = 1000 / ratePerSecond - (Date.now() - started);
      if (spacing > 0) await sleep(spacing);
    }
  }
  console.log("SIGTERM: stopped polling; unacked leases expire on their own");
}

await poll(async (payload, delivery) => {
  console.log(`processing ${payload.kind ?? "job"} (delivery ${delivery})`);
});

That spacing line is the thing push can’t give you. Five requests a second, enforced by the consumer, with the queue as the buffer — the classic reason to keep a puller in front of a fragile downstream.

An idle worker on this loop settles at one call every 16 seconds, roughly 5,400 consume calls a day. They’re free, though rate-limited, so a fleet of forty pollers all set to 500 ms is the configuration that will get you throttled, not the API’s price list.

Retries: the difference that matters

Push retries are the subscription’s, and they’re fast and fixed. Polling retries are the queue’s: a message you don’t ack becomes visible again when the lease expires, delivery_count climbs, and after three deliveries it dead-letters. Two consequences follow.

Spacing under polling is whatever you set the queue’s visibility timeout to — 30 seconds for a chatty retry, 900 for a patient one — and you change it with PATCH /v1/queue/update/{queue} without touching the worker. Under push you get six seconds, always.

And a polling worker can distinguish a permanent failure from a transient one: ack the 422 and record it, leave the 503 unacked. A push endpoint expresses the same decision with a status code, but every non-2xx means the same thing to the dispatcher, so “this will never work” and “try again later” both cost three attempts.

curl -sS "https://api.infrai.cc/v1/queue/stats/jobs-poll" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

in_flight_count climbing while available_count stays flat means your handler is slower than the lease. That’s the signal to shorten the batch or lengthen the timeout.

Cost, and the honest limits

Only publishing is metered — $0.00002 per message, verified 2026-07-26 — and consume, ack, stats and push subscription are free and rate-limited. So the transport choice doesn’t change your bill; it changes your operational surface. Check the live figures with:

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

These per-call rates trend downward and new accounts hold $2 of free credit, so expect the live number to be at or below what’s printed here.

Three limitations on the push side are worth knowing before you commit. There’s no documented unsubscribe route, and calling subscribe a second time with a different URL returned a fresh subscription_id while deliveries kept going to the first endpoint — in practice, plan on deleting and recreating the queue to change destinations. There’s no request signature, so authentication is on you. And a queue that doesn’t exist answers QUEUE_NOT_FOUND rather than creating one for you.

If you want push with real backoff control, QStash is built entirely around that model and lets you configure retries and delays per message. SQS pairs long polling — up to 20 seconds of wait per call, which Infrai’s consume doesn’t support — with Lambda event-source mapping when you’d rather not run a loop. RabbitMQ’s basic.consume with a prefetch count is still the most precise backpressure mechanism in this list if you’re willing to operate the broker.

Pick push when the processor is stateless HTTPS, the work is independent, and nothing downstream is rate-limited. Pick polling when the worker needs to pace itself, run behind a firewall, or decide for itself what a failure means.

References

Browse more queue developer guides