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 up to max_retries 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-27.
The two loops, side by side
| Push subscription | Polling consumer | |
|---|---|---|
| Registration | one call, then nothing to run | a process you keep alive |
| Reachability | needs a public HTTPS URL | works behind NAT, in Docker, on a laptop |
| Delivery latency | ~5 s after publish in our testing | your poll interval, plus ~50 ms server time |
| Batch size | one message per POST in our testing | up to 10 per POST /v1/queue/consume |
| Concurrency | fixed at 10 by the subscription | whatever your loop does |
| Ack | HTTP 2xx from your endpoint | explicit POST /v1/queue/ack |
| Retry on failure | max_retries attempts ≈6 s apart, then DLQ | lease expiry, max_retries on the queue, spacing = visibility timeout |
| Backpressure | none — it keeps arriving | natural: you stop calling consume |
| Consumer-side cost | zero API calls | one 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","secret":"whsec_rotate_me","max_retries":5}'
{
"ok": true,
"data": {
"subscription_id": "sub_iQsZAKJ05LHpqJD1VQgmyRoA",
"queue": "jobs-push",
"concurrency": 10,
"max_retries": 5,
"active": true,
"started_at": "2026-07-27T11:55:13.362045Z"
}
}
max_retries is yours to set; concurrency comes back at 10 and isn’t a knob on this call, so treat ten deliveries in flight as the contract. The secret is stored, never echoed — a later read shows only a secret_fingerprint — and it’s what your handler uses to prove the POST came from the queue rather than from anyone who learned your URL. The URL itself is SSRF-checked at registration: a loopback address or a plain http:// target is refused with a 400 WEBHOOK_URL_INVALID instead of a subscription that can never deliver.
What lands on your endpoint is a POST carrying an envelope and two identifying headers (X-Infrai-Subscription-Id and X-Infrai-Queue):
{
"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 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 — until the budget runs out, after which dlq_count on the queue went to 1. The count is configurable; the spacing is not, so there’s no exponential curve to tune.
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, message_id: 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-27 — and consume, ack, stats and the push subscription routes 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.
Moving a destination is a two-step operation rather than an edit: list what’s registered, delete the one you’re replacing, subscribe the new URL.
curl -sS "https://api.infrai.cc/v1/queue/push_subscription/list/jobs-push" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS -X DELETE "https://api.infrai.cc/v1/queue/push_subscription/delete/jobs-push/sub_iQsZAKJ05LHpqJD1VQgmyRoA" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The list route is the one to check after a deploy: it returns each subscription’s url, active, max_retries and secret_fingerprint, which is how you catch a staging endpoint that’s still subscribed to a production queue. Two limitations remain on the push side whatever you do. Delivery spacing isn’t configurable — the API doesn’t support a per-subscription backoff curve — and a queue that doesn’t exist answers QUEUE_NOT_FOUND on subscribe rather than creating one for you — publish is the route that will happily invent a lane from a typo, subscribe isn’t.
If you want push with per-message backoff control, QStash is built entirely around that model and lets you configure retries and delays per publish — buy it if the retry curve is the feature you’re shopping for. SQS pairs long polling, up to 20 seconds of wait per call, with Lambda event-source mapping when you’d rather not run a loop at all; Infrai’s consume returns immediately instead, which is a real difference in idle call volume and worth pricing if your fleet is large.
Neither shape needs a second account
Whichever transport you pick, the failure path stays on one key: a message that exhausts its retries goes to the dead-letter queue, POST /v1/errors/capture records why with the payload attached, and POST /v1/email/send tells a human — same credential, same bill, no lock-in at the call site because all three are plain REST with a bearer token. There’s no client SDK you have to adopt and nothing proprietary in the wire format, so a worker written against this can be pointed elsewhere by changing a base URL.
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.