Push or poll when your queue consumers are serverless functions

Why HTTPS push usually wins for function-based consumers, the invocation arithmetic behind that answer, and three push caveats worth checking before you subscribe.

Push, in most cases. A polling loop assumes a process that stays alive, and a serverless function is the opposite of that — you’d be paying for wall-clock time to sit in a while loop asking an empty queue for work. Infrai’s queue offers both shapes on the same queue, so the decision is reversible, but the default for functions is a push subscription that turns each message into an ordinary HTTP request your platform already knows how to scale.

The exception is rate limiting. If the work behind each message hits an API capped at 5 rps, push will happily invoke you far faster than that, and pulling in controlled batches from a scheduled function is the saner design.

The invocation arithmetic

This is the part that decides it for most teams, and it’s arithmetic rather than architecture.

Push delivers one message per HTTP request. A thousand messages is a thousand invocations, each short. Polling delivers up to 10 messages per call, so a scheduled function that runs every minute and drains a batch handles the same thousand messages in perhaps a hundred invocations — but each one is longer, and the ones that find an empty queue are pure waste. Below a few thousand messages a day the difference is noise. Above it, push costs more in invocations and less in idle time, and which of those your bill cares about depends on your platform’s pricing shape. In practice the crossover sits wherever your platform stops charging per-invocation and starts charging per-GB-second.

HTTPS pushPolling from a scheduled function
Who pays while the queue is emptyNobodyYou, once per schedule tick
Invocations per 1,000 messages~1,000~100 (batches of 10)
Latency to first deliveryImmediateUp to one schedule interval
BatchingOne message per requestUp to 10 per call
AckingHTTP 2xx from your handlerExplicit POST /v1/queue/ack
Respecting a downstream rate limitHard — concurrency is the only dialNatural — you control the loop
Tearing it downNo unsubscribe route todayStop scheduling the function

Subscribing, and what comes back

Register the endpoint once against the queue:

curl -sS -X POST "https://api.infrai.cc/v1/queue/push_subscribe/serverless-events" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://app.example.com/api/queue-consumer"}'
{
  "ok": true,
  "data": {
    "subscription_id": "sub_7GQltk7IbUDsh98TChxZSO5o",
    "queue": "serverless-events",
    "concurrency": 10,
    "active": true,
    "max_retries": 3
  }
}

Two numbers in that response are your real operating envelope. concurrency is 10, which is the fan-out ceiling — your platform may be able to run 200 functions at once, but the subscription won’t ask it to. max_retries is 3, matching the queue’s delivery budget, after which the message goes to the dead-letter queue.

The queue itself is created first, with its failure lane:

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

The handler

A push consumer is just a route. Return 2xx and the message is done; return anything else, or time out, and it comes back.

// api/queue-consumer.js — a standard Node 22 request handler
import process from "node:process";

const SHARED_SECRET = process.env.QUEUE_CONSUMER_SECRET;

export default async function handler(req, res) {
  if (req.method !== "POST") {
    res.status(405).json({ error: "method not allowed" });
    return;
  }
  // The subscription posts to whatever URL you registered, so authenticate it yourself.
  if (SHARED_SECRET && req.headers["x-consumer-secret"] !== SHARED_SECRET) {
    res.status(401).json({ error: "unauthorized" });
    return;
  }
  try {
    const message = req.body;
    console.log("handling", message.message_id, JSON.stringify(message.payload));
    await doTheWork(message.payload);
    res.status(200).json({ ok: true });
  } catch (err) {
    // A non-2xx is the retry signal. Three of these and the message dead-letters.
    console.error("handler failed", err.message);
    res.status(500).json({ error: err.message });
  }
}

async function doTheWork(payload) {
  if (!payload || typeof payload !== "object") throw new Error("unusable payload");
}

Keep it short. There’s no way to extend a push delivery’s clock from inside the handler (the polling path gets a 300-second visibility lease, the push path does not), so anything slow should be recorded and continued elsewhere rather than held open. Everything above runs on Node 22 with no dependencies — fetch and the request object are both built in.

If you pull instead

Pulling from a function means a scheduled invocation that drains a bounded batch and exits. The important detail is the ack — and it has a sharp edge.

import process from "node:process";

const BASE = "https://api.infrai.cc";
const QUEUE = "serverless-events";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not configured for this function");
const HEADERS = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

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;
}

export async function drain(budgetMs = 25_000) {
  const deadline = Date.now() + budgetMs;
  let done = 0;
  while (Date.now() < deadline) {
    const { items } = await post("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
    if (!items.length) break;
    for (const message of items) {
      console.log("processing", message.message_id, message.delivery_count);
      const result = await post("/v1/queue/ack", { queue: QUEUE, receipt_handle: message.message_id });
      // HTTP 200 does NOT mean the ack landed. Check the flag.
      if (!result.acked) console.warn("ack ignored — lease expired?", message.message_id);
      else done++;
    }
  }
  return done;
}

That result.acked check is the one thing we’d insist on in a serverless consumer. Acking a handle the queue no longer recognises returns HTTP 200 with "acked": false — no error, no exception, nothing a res.ok test would catch — and the message quietly comes back for redelivery while your function reports success. In a long-lived worker you’d notice the duplicates; in a function that froze between the work and the ack, you won’t.

Confirm what’s left after a run:

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

Caveats before you subscribe

The subscribe call doesn’t validate the URL. Register a typo or a preview deployment that has since been torn down and every message burns its three attempts against a dead host before dead-lettering — the failure looks like a queue problem and is a DNS problem.

There’s also no unsubscribe route today, which is the limitation that matters most for ephemeral environments. If your preview deployments get unique hostnames, don’t point push subscriptions at them.

And per-message rate control doesn’t exist on the push side; concurrency 10 is what you get. If you’re protecting a downstream API, poll.

Amazon SQS with a Lambda event-source mapping gives you batching and push semantics together, and if you’re already inside that ecosystem it’s a better fit than anything described here. Upstash QStash is push-only and built precisely for this shape, so if HTTP delivery with retries is the entire requirement it’s a fair comparison too. Google Cloud Pub/Sub likewise supports both modes with more knobs. What you’d be trading away is that one key here also reaches storage, email and cron — so the function that finishes a job can email the user without a second vendor in the loop.

References

Browse more queue developer guides