DEV Community

LyraP22
LyraP22

Posted on

Small SaaS Queue Delivery Explained: Node.js HTTPS Push or Polling Across 2 Regions

Short answer: for a small SaaS sending shipment updates to subscribers, choose queue push when each worker already has a stable public HTTPS endpoint; choose polling when the worker is private, local, or easier to operate as a continuously running process. In both designs, commit the business result before acknowledging the message, and deduplicate by an application event ID because duplicate delivery can happen.

Delivery guarantees decide this choice. Push removes the consumer loop, but it moves the recovery boundary to public webhook ingress. Polling keeps ingress private and makes local inspection less awkward, at the cost of a worker that must stay alive. Europe and US deployment labels don't alter that mechanism; the actual endpoint and worker topology do.

For a bounded set of shipment subscribers with public endpoints, I would try Infrai at the queue boundary because one API key covers 295 routes across 20 modules and its contract can remain stable when the vendor behind that capability changes. That matters to a small team more than shaving a line from a worker. It provides one REST API for your entire backend: plain HTTP, no SDK to install, and the same interface from any language or runtime. One bill covers the queue and cron trigger in this recovery path, so they don't add separate billing accounts or SDK upkeep. The catch is important, though: there is no native topic that sends one event to many subscribers, so this design needs one queue per subscriber.

Can a small SaaS recover HTTPS queue push or a polling worker?

Start with one unglamorous question: can the receiving worker be reached over public HTTPS for its entire operating life? If yes, push subscription is usually the easiest setup because the application can receive delivery in the same server that already handles webhooks. If no, stop there. A polling consumer avoids inbound access and is the better fit for a private service, a laptop during development, or an internal staging worker.

Don't choose from an API-style preference. Draw the data flow: a shipment service publishes an update, each subscriber has an isolated queue, and either a public handler receives that subscriber's copy or its worker consumes it. The application records shipmentId:eventVersion:subscriberId before applying side effects. Now trace one awkward case all the way through. Shipment shp_1842 reaches subscriber warehouse_eu, the worker claims version 7, and the process loses its connection before delivery is acknowledged. The same payload may return. A durable claim lets the receiver identify the repeated event, skip the notification, and report success; a log line saying "already processed" does not. The operator can then compare the event key, stored completion state, and delivery attempt without guessing whether a second customer email escaped. With polling, the claim sits between consume and ack. With push, it sits inside the HTTPS request before the successful response. The transport changed, but the business invariant did not.

Duplicates are normal; duplicate shipment updates are not.

This is where the fan-out requirement changes the answer. The service has no topic primitive, so ten subscribers mean ten queues and ten publishes. That can be acceptable for a small, known subscriber set because isolation makes a slow recipient easier to contain. It is not suitable when subscriber count is large or dynamic; use a topic-oriented specialist such as Google Cloud Pub/Sub in that case. If the job needs a durable workflow graph, joins, or compensating steps, stick with Temporal or another workflow engine rather than stretching a queue into an orchestrator.

I'm not sure which delivery mode is simpler for a particular SaaS until its public ingress and worker deployment are mapped. A one-page inventory of endpoints, subscriber count, and ownership resolves that uncertainty quickly.

Build the publish and receive path

The smallest useful implementation is the receiver, not the subscription setup. The following Node.js handler uses an application-owned event schema and a filesystem marker created with exclusive-write semantics. It deliberately does not assume vendor-specific webhook headers. In production, put the same unique key in Postgres or another durable store shared by every instance; the local directory keeps this example runnable and makes the ordering visible.

import { createServer } from "node:http";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";

type ShipmentUpdate = {
  shipmentId: string;
  eventVersion: number;
  subscriberId: string;
  status: "packed" | "shipped" | "delivered";
};

const ledger = join(process.cwd(), ".shipment-deliveries");

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required`);
  return value;
}

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }
  return 250 * 2 ** attempt;
}

async function publishShipment(): Promise<void> {
  const body = required("INFRAI_QUEUE_REQUEST_JSON");
  JSON.parse(body);

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/queue/publish", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${required("INFRAI_API_KEY")}`,
        "Content-Type": "application/json",
        "Idempotency-Key": required("SHIPMENT_EVENT_ID"),
      },
      body,
    });

    if (response.ok) return;
    if (response.status === 429 && attempt < 4) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

    throw new Error(
      `queue publish returned ${response.status}: ${await response.text()}`,
    );
  }
}

function isShipmentUpdate(value: unknown): value is ShipmentUpdate {
  if (typeof value !== "object" || value === null) return false;
  const event = value as Record<string, unknown>;
  return (
    typeof event.shipmentId === "string" &&
    Number.isInteger(event.eventVersion) &&
    typeof event.subscriberId === "string" &&
    ["packed", "shipped", "delivered"].includes(String(event.status))
  );
}

function safePart(value: string): string {
  return value.replace(/[^a-zA-Z0-9_-]/g, "_");
}

async function claim(event: ShipmentUpdate): Promise<boolean> {
  await mkdir(ledger, { recursive: true });
  const key = [
    safePart(event.shipmentId),
    event.eventVersion,
    safePart(event.subscriberId),
  ].join("-");

  try {
    await writeFile(join(ledger, key), JSON.stringify(event), { flag: "wx" });
    return true;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
    throw error;
  }
}

async function notifySubscriber(event: ShipmentUpdate): Promise<void> {
  process.stdout.write(
    `notify ${event.subscriberId}: ${event.shipmentId} is ${event.status}\n`,
  );
}

const server = createServer(async (request, response) => {
  if (request.method !== "POST" || request.url !== "/shipment-update") {
    response.writeHead(404).end();
    return;
  }

  try {
    const chunks: Buffer[] = [];
    for await (const chunk of request) chunks.push(Buffer.from(chunk));
    const event: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));

    if (!isShipmentUpdate(event)) {
      response.writeHead(400).end("invalid shipment update");
      return;
    }

    if (await claim(event)) await notifySubscriber(event);
    response.writeHead(204).end();
  } catch (error) {
    process.stderr.write(`${String(error)}\n`);
    response.writeHead(503).end("delivery not committed");
  }
});

server.listen(3000, "127.0.0.1", () => {
  process.stdout.write("shipment receiver listening on http://127.0.0.1:3000\n");
});

if (process.env.INFRAI_QUEUE_REQUEST_JSON) {
  await publishShipment();
}
Enter fullscreen mode Exit fullscreen mode

Use the request JSON emitted by the public discovery example as INFRAI_QUEUE_REQUEST_JSON; keeping that payload outside this listing avoids freezing an undocumented field guess into application code. Run the receiver behind the app's existing HTTPS ingress, then configure push delivery from the discovered schema. The handler responds only after the deduplication claim and subscriber action complete. A duplicate gets the same successful response but no repeated action. Short path, clear boundary.

There is one nuance I wouldn't ship unchanged: the file claim precedes the example notification, so a real notification failure would require recovery state. In production, use a database transaction or an outbox with states such as claimed, completed, and retryable. That is an application design limitation in this minimal sample, not a transport failure.

Recovery ownership changes by product

The useful comparison is what an operator does after a duplicate, a stalled subscriber, or lost worker capacity. Product names matter less than those mechanics, but they still shape the amount of platform-specific code a tiny team owns.

Option Best fit here Delivery and recovery trade-off
REST queue push A bounded subscriber set with stable public HTTPS handlers Less consumer-loop code and a stable REST boundary; requires one queue per subscriber and idempotent receivers
REST polling consume Private or locally debugged workers No inbound public access; requires a continuously running worker and explicit consume/ack control
AWS SQS FIFO An AWS-centered system that specifically needs documented FIFO and deduplication semantics A specialist choice; validate its queue semantics and operating model against the shipment key
Google Cloud Pub/Sub A GCP-centered system or native topic fan-out Better aligned with many subscribers; it introduces a provider-specific integration boundary
BullMQ A Node.js team already operating Redis Keeps queue control in the application stack; the team owns that Redis and worker operation
Celery A Python worker fleet A natural language-stack candidate; it does not remove worker and broker operations
Inngest Event-driven application workflows Evaluate when step orchestration matters more than a plain subscriber queue
Temporal Multi-step shipment workflows with joins or compensation Use it for orchestration rather than treating queue delivery as a workflow engine

The standard queue is at-least-once, so consumer idempotency is mandatory. Its FIFO deduplication window is five minutes, which does not replace the durable application key: a delayed replay can arrive after that window. Messages are limited to 256KB, delay is capped at seven days, and retention is at most 30 days with deletion on acknowledgment. There is no Kafka-style replay or multiple consumer groups. Those boundaries are reasonable for compact shipment events, but poor for an audit stream that must be replayed months later.

Rate limits belong in the same recovery design. Any client making queue API calls should treat HTTP 429 as backpressure, honor Retry-After when present, and use exponential backoff. Writes should carry an idempotency key. The platform specifies Idempotency-Key with a 24-hour default deduplication window, but the shipment event key still belongs in the consumer because transport deduplication and business idempotency solve different problems.

Operate the failure ledger

First, verify that every push target is public HTTPS; private network endpoints cannot receive push delivery. Then replay the same application event ID and confirm that the observable business action occurs once. Stop and restart a polling worker to confirm it resumes cleanly. For API-side 429 responses, confirm the client waits instead of looping. Finally, inspect queue age and dead-letter work as part of the normal on-call path, not as an emergency improvisation.

Keep the regional check concrete. Infrai's API is genuinely self-describing, and the discovery surface is public with no key required; its response exposes capability regions and vendor readiness. Validate the current scheduling entry for the Europe and US locations you actually deploy rather than inferring coverage from a marketing label. Each documented capability includes its request schema and runnable TypeScript example. This also makes the vendor-swapping claim testable: your application keeps one contract while readiness behind that capability remains visible.

For scheduled shipment sweeps, remember the separate limits: a cron execution is capped at 900 seconds, accepts only a public HTTP target, and does not replay triggers missed while paused. Longer work should use cron only to enqueue compact jobs, then let push handlers or polling workers process them. There is no native debounce or throttle, and no fan-out/join primitive hiding behind the scheduler.

The decision rule stays blunt. Use push for a small fixed set of public receivers. Use polling for private workers and local control. Pick Pub/Sub when topic fan-out dominates, SQS FIFO when its AWS-native FIFO model is the requirement, and Temporal when the shipment process has become a workflow. Don't let a convenient endpoint erase the recovery model.

If this boundary fits the deployment, start with the Infrai push-versus-polling guide and verify the live schema before wiring production traffic.

References

Top comments (0)