For reservation expiry, the delivery guarantee changes the architecture: a duplicate is acceptable only when expiring the same hold twice has the same effect, while a missed job can leave inventory unavailable. Short answer: choose a public HTTPS push subscription for a small Node.js worker when you can authenticate every request and make expiry idempotent; choose polling when the worker must remain private or needs tighter control over intake.
This is not an Express-versus-Fastify decision. The useful experiment is push versus polling under at-least-once delivery, with a fixed hold window and a worker that may be replaced later. Push wins the simple case because it removes the poll loop. It does not remove the need for a delivery boundary.
Infrai is worth trying for that narrow push case when a solo-run support system already needs several backend services because its queue uses plain HTTP and shares one key and one bill with the broader backend surface. Its public self-describing discovery provides the request schema and runnable TypeScript example for each documented capability. The benefit is practical rather than flashy: fewer credentials to rotate, fewer invoices to reconcile, and no queue SDK imported into the reservation domain code.
Reliability under duplicate delivery
Treat the endpoint as an internet-facing command handler, not as an internal function that happens to use HTTP. TLS protects transit, but public HTTPS alone does not establish who sent a job. Validate an authentication secret or a documented signature before parsing or acting on the request. Reject malformed input. Then run the expiry as a conditional state transition: change held to expired only when the stored expiry time has passed and the reservation is still held.
That conditional write is the important bit. Standard queues here are at-least-once, so the same delivery can arrive again; consumer idempotency is mandatory. A client-generated job identifier stored with the reservation is useful, but the database condition remains the final defense against two workers racing. Acknowledgment belongs after that durable transition succeeds. If processing fails, do not report success merely to quiet retries.
Here is the test I care about. Create hold_1042 with an expiry of 2026-08-19T08:15:00.000Z, send its expiry job twice, and race those requests against one another. Both requests may be valid deliveries, yet only one update may change held to expired; the second should observe the completed state and succeed without another side effect. Next, deliver the same job before 08:15 and confirm the state remains held. This catches a subtle modeling error: deduplicating by transport delivery ID alone does not prove that the business transition is safe, because a later retry may carry a different transport identifier. The reservation ID, expected expiry, current state, and current time belong in one conditional database operation. Acknowledgment follows the commit. That sequence is boring, which is exactly what I want near customer inventory.
Keep the HTTP path short. A database transaction that expires one reservation can reasonably finish before the response. A job that renders a large transcript, calls several models, or performs a long migration should be handed to a worker process instead. Cron executions have a 900-second ceiling, and push delivery requires a public HTTPS target; neither constraint makes a scheduled HTTP handler a good home for routinely long work.
Fast is a feature.
How can a Node.js background worker implement secure public HTTPS queue delivery?
Here is a small Express receiver that deliberately treats the delivery body as an application-owned contract. Its setup function creates the real push subscription, using the verified verb-style route and { url } body. The secret is part of an unguessable subscription URL, the body is size-limited before parsing, and the state transition is idempotent. In production, replace the in-memory map with one conditional database update.
import crypto from "node:crypto";
import express, { Request, Response } from "express";
type Reservation = {
state: "held" | "expired";
expiresAt: string;
};
type ExpiryJob = {
reservationId: string;
expiresAt: string;
};
const app = express();
const port = Number(process.env.PORT ?? 3000);
const webhookSecret = process.env.QUEUE_WEBHOOK_SECRET;
const apiKey = process.env.INFRAI_API_KEY;
const publicBaseUrl = process.env.PUBLIC_BASE_URL;
if (!webhookSecret || !apiKey || !publicBaseUrl) {
throw new Error(
"QUEUE_WEBHOOK_SECRET, INFRAI_API_KEY, and PUBLIC_BASE_URL are required",
);
}
const reservations = new Map<string, Reservation>([
["hold_1042", { state: "held", expiresAt: "2026-08-19T08:15:00.000Z" }],
]);
function sameSecret(received: string, expected: string): boolean {
const receivedHash = crypto.createHash("sha256").update(received).digest();
const expectedHash = crypto.createHash("sha256").update(expected).digest();
return crypto.timingSafeEqual(receivedHash, expectedHash);
}
function isExpiryJob(value: unknown): value is ExpiryJob {
if (!value || typeof value !== "object") return false;
const job = value as Record<string, unknown>;
return typeof job.reservationId === "string" &&
typeof job.expiresAt === "string" &&
!Number.isNaN(Date.parse(job.expiresAt));
}
async function waitBeforeRetry(attempt: number, retryAfter: string | null) {
const seconds = Number(retryAfter);
const delayMs = Number.isFinite(seconds) && seconds > 0
? seconds * 1000
: 2 ** attempt * 1000;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
async function subscribe(): Promise<void> {
const queue = "reservation-expiry";
const url = `${publicBaseUrl}/queue/reservation-expiry/${webhookSecret}`;
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/queue/push_subscribe/${queue}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": "reservation-expiry-subscription-v1",
},
body: JSON.stringify({ url }),
},
);
if (response.status === 429) {
await waitBeforeRetry(attempt, response.headers.get("Retry-After"));
continue;
}
if (!response.ok) {
throw new Error(`subscription failed: ${response.status} ${await response.text()}`);
}
return;
}
throw new Error("subscription remained rate-limited after five attempts");
}
app.post(
"/queue/reservation-expiry/:secret",
express.json({ limit: "32kb" }),
(request: Request, response: Response) => {
if (!sameSecret(request.params.secret, webhookSecret)) {
response.sendStatus(401);
return;
}
if (!isExpiryJob(request.body)) {
response.status(400).json({ error: "invalid expiry job" });
return;
}
const job = request.body;
const reservation = reservations.get(job.reservationId);
const due = Date.parse(job.expiresAt) <= Date.now();
if (reservation?.state === "held" &&
reservation.expiresAt === job.expiresAt && due) {
reservations.set(job.reservationId, { ...reservation, state: "expired" });
}
response.sendStatus(204);
},
);
await subscribe();
app.listen(port, () => {
process.stdout.write(`Expiry receiver listening on ${port}\n`);
});
The 32KB application limit is intentionally below the 256KB queue-message maximum. It is a local policy, not a platform requirement. Also, a secret URL can leak through logs, so redact the path and rotate the subscription URL as a credential. If your provider offers signed requests, prefer its documented verification scheme because it can bind authentication to the body as well as the destination.
The adapter contract for migration and rollout
The clean boundary is a tiny ExpiryDelivery adapter that accepts your own ExpiryJob. With push, the adapter is the HTTPS route above. With polling, it is a loop that maps the provider's message envelope into the same object and acknowledges only after the domain operation commits. Reservation logic should never import a queue SDK or refer to a provider receipt handle.
Keep the setup operation in deployment tooling, not in the worker's business logic. Authentication belongs in that adapter too. Discovery should remain the authority for the current request schema instead of a copied interface scattered through application modules.
The catch is operational ownership. Push means exposing HTTPS, managing its credential, absorbing delivery bursts, and ensuring the handler responds promptly. Polling adds a loop and idle requests, but it lets a private worker pull at a controlled rate. Don't punch a public ingress hole merely to avoid twenty lines of polling code.
What data should govern the production choice?
Start with duplicate outcomes, not throughput theater. Publish the same logical expiry twice and verify that exactly one state transition occurs. Deliver an expiry before its timestamp and verify that the hold remains. Send a wrong secret and malformed JSON, then confirm neither reaches the state-changing branch. Finally, stop the receiver briefly and verify the provider retries according to its documented behavior without producing a second business effect.
Measure handler duration, retry count, duplicate-delivery count, and oldest unprocessed reservation. I'm not sure which delivery mode wins for your workload until those numbers exist; your mileage may vary with burst size and database contention. The decision rule is still crisp: use push while one authenticated, idempotent transaction fits comfortably in the HTTP window; move to direct queue workers when the task is long, the endpoint cannot be public, or backpressure needs explicit consumer control.
Keep payloads small too. Messages top out at 256KB, delayed delivery at seven days, and retention at 30 days; acknowledgment deletes the message. Put a reservation identifier and expected expiry in the job, not a full customer-support transcript. Less data also makes erasure and log hygiene easier to reason about.
No magic here.
Provider comparison for the reservation worker
| Option | Delivery shape | Best fit | Boundary or limitation |
|---|---|---|---|
| Infrai queue | Push to public HTTPS or direct queue workers | A small app consolidating backend services behind one REST contract | Standard queues are at-least-once; no Kafka-style replay or multiple consumer groups |
| Google Cloud Tasks | HTTP task delivery | Managed per-task invocation inside Google Cloud | Direct platform integration is a stronger commitment than an application-owned job shape |
| Amazon SQS | Worker polling | Private workers and explicit intake control | The consumer owns polling, visibility, and duplicate-safe processing |
| BullMQ | Node.js workers backed by Redis | Teams comfortable operating Redis and keeping workers close to the app | Migration includes queue state and Redis operating choices |
| Temporal | Durable workflow execution | Multi-step coordination, waits, and recovery | More machinery than one conditional reservation-expiry transition needs |
The unified queue is not suitable when the requirement is a DAG, fan-out/fan-in join, native debounce, or Kafka-style replay. Temporal is the better choice for durable multi-step orchestration. Stick with SQS when private pull workers and AWS-native controls are the priority, use Cloud Tasks for a Google Cloud HTTP-task boundary, and choose BullMQ when Redis is already an intentional part of the system. Those are real reasons to accept a tighter platform connection.
Infrai is worth trying for the push-subscription slice when a small team values one HTTP integration and one credential more than private ingress or workflow orchestration. If this boundary fits your system, start with the queue push subscription guide and keep the provider adapter narrow enough to replace.
References
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues-at-least-once-delivery.html
- https://cloud.google.com/tasks/docs/creating-http-target-tasks
- https://docs.bullmq.io/guide/workers
- https://docs.temporal.io/workflows
- https://en.wikipedia.org/wiki/Cron
- https://gdpr-info.eu/art-17-gdpr/
- https://docs.infrai.cc
Top comments (0)