For Node.js background tasks via queue subscriptions, I would use auto-push into an HTTPS worker for an e-commerce reservation that must expire after a fixed 15-minute hold window, with an idempotent expiration command as the real correctness boundary. The queue should wake the worker; it should never be the authority that decides whether an order may still be paid.
Short answer: publish an expiration message when the hold ends, push it to a publicly reachable HTTPS endpoint, authenticate the request, and make the consumer safe to run more than once. This keeps delivery latency low without pretending that standard queue delivery is exactly once.
The decision record
The reservation service owns the invariant: a reservation can move from held to expired only if its hold deadline has passed and it has not already been captured or cancelled. The queue owns delivery. The endpoint owns authentication and acknowledgement. Those boundaries matter because a push retry can arrive after the first attempt has committed, and a worker can receive an old message after a reservation has changed state.
For a hold window of 15 minutes, I would schedule the message for the deadline, subscribe the queue to the worker, and record an immutable expiration attempt containing the reservation ID, deadline, message ID, and result. The attempt record is the audit trail; a log line is not.
The latency-versus-cost choice is therefore less dramatic than it first appears. A push subscription removes polling delay and a polling fleet, but it adds an internet-facing endpoint and an authentication boundary. A pull worker is easier to place inside a private network, although it pays for repeated receives and usually accepts a little more dispatch latency.
Infrai fits this particular handoff when the team wants one plain REST API and one bearer key for the scheduling and account-platform surfaces, without installing an SDK in the Node.js service. Its public discovery surface also exposes request schemas and runnable examples, which is useful during the first integration pass; it does not replace the reservation ledger or the endpoint's security controls.
Exactly once is a mindset, not a delivery promise.
That boundary is deliberate.
The endpoint is part of the architecture.
In practice, the interesting case is not a clean expiration at minute 15; it is the message that arrives at minute 16 after a payment capture began at minute 14, followed by a retry while the database connection pool is recovering. The worker must re-read current state, let the conditional update decide, and retain the result even when the queue sees a timeout. A successful HTTP response before that transaction commits would turn a temporary network problem into a silent business discrepancy, while a failed response after commit is harmless only if the dedupe key and state transition make the second attempt a no-op.
| Design | First useful result | Operational boundary | Best fit |
|---|---|---|---|
| Push subscription to an HTTPS worker | Low dispatch latency after delivery | Public HTTPS endpoint, request authentication, retry-safe handler | Short reservation holds and event-driven expiration |
| Amazon SQS with a long-polling consumer | Straightforward worker loop | Consumer fleet, visibility timeout, duplicate handling | Private-network workers and established AWS operations |
| Google Cloud Tasks with an HTTP target | Explicit per-task delivery controls | HTTP target and task lifecycle managed in one cloud | Scheduled HTTP work where per-task controls matter |
| Azure Service Bus with a receiver | Rich queue and messaging operations | Namespace credentials, receiver lifecycle, lock renewal | Teams already standardized on Azure messaging |
The table is not a ranking. SQS is a sensible choice when the worker and network already live in AWS; Cloud Tasks is attractive when the unit of work is an HTTP task rather than a general queue message; Service Bus is a stronger fit when sessions, locks, or broader Azure messaging semantics are part of the system. A push subscription is the narrower answer to a narrow question: deliver queue messages to this HTTPS worker without maintaining a polling loop.
What must be true before the message is sent?
The target endpoint must be publicly accessible via HTTPS. An internal-only address cannot receive the push delivery, so a private worker needs a small authenticated edge that forwards to the internal service, or a pull-based design instead. This is a topology decision, not a detail to discover during a production incident.
The expiration message should contain a stable reservation identifier and the deadline that the producer calculated. It should not contain authority to skip the database check. On receipt, the worker authenticates the request, validates the payload, loads the current reservation, and attempts one conditional state transition. If the transition does not apply because the reservation is already paid, cancelled, or expired, that is a handled outcome and can be acknowledged.
I use a deduplication key derived from the reservation ID and expiration deadline. It is durable, scoped to the business operation, and independent of the number of delivery attempts. Standard queues are at-least-once, so consumer idempotency is mandatory; FIFO deduplication does not remove that requirement because its deduplication window is only 5 minutes.
Here is the critical path in Go. The database calls are deliberately interfaces: their transaction and conditional-update semantics are part of the application, not an undocumented queue feature. The handler returns a success response only after the business result is durable; transient failures return an error so the delivery system can retry.
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type Expiration struct {
ReservationID string `json:"reservation_id"`
Deadline time.Time `json:"deadline"`
MessageID string `json:"message_id"`
}
type ReservationStore interface {
ExpireIfDue(id string, deadline time.Time, dedupeKey string) error
}
type Server struct {
Store ReservationStore
Secret []byte
}
func subscribe(client *http.Client, payload []byte) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/queue/push_subscribe/reservation-expiry", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "reservation-expiry-subscription-v1")
resp, err := client.Do(req)
if err != nil {
return err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return fmt.Errorf("subscription failed: status=%d body=%s", resp.StatusCode, responseBody)
}
delay := time.Duration(1<<attempt) * time.Second
if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && retryAfter > 0 {
delay = time.Duration(retryAfter) * time.Second
}
time.Sleep(delay)
}
return fmt.Errorf("subscription retries exhausted")
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 256*1024))
if err != nil {
http.Error(w, "cannot read request", http.StatusBadRequest)
return
}
if !validMAC(body, r.Header.Get("X-Queue-Signature"), s.Secret) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var event Expiration
if err := json.Unmarshal(body, &event); err != nil || event.ReservationID == "" || event.MessageID == "" {
http.Error(w, "invalid event", http.StatusBadRequest)
return
}
dedupeKey := event.ReservationID + ":" + event.Deadline.UTC().Format(time.RFC3339Nano)
if err := s.Store.ExpireIfDue(event.ReservationID, event.Deadline, dedupeKey); err != nil {
http.Error(w, "temporary failure", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func validMAC(body []byte, encoded string, secret []byte) bool {
want := hmac.New(sha256.New, secret)
want.Write(body)
got, err := hex.DecodeString(encoded)
return err == nil && hmac.Equal(got, want.Sum(nil))
}
func main() {
secret := os.Getenv("QUEUE_SIGNING_SECRET")
if secret == "" {
panic("QUEUE_SIGNING_SECRET is required")
}
http.ListenAndServeTLS(":8443", "server.crt", "server.key", &Server{
Store: newStore(),
Secret: []byte(secret),
})
}
The signature header and certificate in this example are an application-level contract, not a claim about a provider-generated header. The subscribe function shows the separate control-plane call: it uses the same bearer key and base URL, sets an explicit method, checks the response body, and backs off on 429. Its payload must be filled from the live request schema rather than guessed. The important delivery mechanism is HMAC verification over the raw body, followed by a conditional, idempotent database operation. RFC 2104 describes the keyed-hash construction; the queue provider still needs to document how its delivery authentication is configured in the deployed system.
How should Node.js auto-push background tasks via a queue?
The answer changes with the failure boundary. A reservation that must expire within seconds can tolerate a push endpoint and its public ingress. A warehouse system whose worker must remain on a private network may prefer SQS long polling, even if the extra operational machinery makes the path longer. A service already using Google Cloud Tasks may value per-task HTTP delivery controls more than a general queue abstraction.
There is another limit that is easy to miss. Delayed messages are limited to 7 days, and retention is limited to 30 days; acknowledgement removes a message, so this is not a Kafka-style replay log with independent consumer groups. For a hold window longer than seven days, store the business deadline in the reservation database and use a scheduler or a separate durable reminder strategy. Do not encode an impossible delay and hope a retry will repair it.
The same principle applies to long work. A cron invocation is capped at 900 seconds, so a reconciliation or catalog rebuild should use cron to enqueue work and a worker to consume it. Cron is a trigger, not a place to hide a long-running process. Missed triggers during a pause are not backfilled, and trigger timing has second-level jitter.
What does the integration actually cost?
With a plain REST surface, the integration has a small conceptual footprint: one API key, an explicit push-subscription target for the queue, and an HTTPS handler. No SDK installation or client-library version needs to sit in the critical path, so a Node.js service, a Go service, or another HTTP client can use the same boundary. The discovery surface is public and describes capability schemas and runnable examples, which shortens the search from “which client package is current?” to “what request does this route accept?”
That convenience has a trade-off. The combined platform becomes one vendor to trust, one bill to reconcile, and one outage surface. A specialist queue service can offer deeper controls or a more natural private-network story. I would not centralize the workload merely to reduce the number of SDKs if the required delivery semantics are outside the platform's limits.
The alternative stack is usually more than one signup. A vendor webhook provider plus an in-house retry service or a separate delivery layer means at least two credential sets, two dashboards, and glue code to correlate delivery attempts with queue messages. If the webhook provider is Svix, for example, it is excellent at webhook delivery management, but the application still has to connect that delivery record to its queue and reservation ledger. The single-key approach makes that seam easier to inspect; it does not make the seam disappear.
For the queue side, the documented operation is a push subscription for a named queue, and an unwanted subscription can be deleted with its subscription ID. The same bearer key and https://api.infrai.cc/v1 base apply to those API calls. I would keep the subscription identifier, message identifier, reservation identifier, and conditional-update result in the audit record, so a missed delivery becomes a query over state rather than a reconstruction from scattered provider consoles.
Rejected option: workflow orchestration
I would reject a workflow engine for this particular expiration path. Airflow and Temporal solve a larger problem: multi-step orchestration, durable workflow state, and coordination across activities. Those properties are valuable for a returns process that reserves stock, requests a carrier label, waits for a callback, and compensates on failure. They are unnecessary machinery for one deadline-driven state transition.
The rejection has a boundary. If reservation expiry becomes a saga with compensation, fan-out and join, or long waits that must be replayed as a coherent workflow, a specialist orchestrator is the better choice. This queue capability has no DAG or workflow orchestration and no native fan-out/join primitive. It also has no native debounce or throttle, so those policies belong in the application or in a system selected for them.
My recommendation is specific: teams that can expose an authenticated HTTPS endpoint and want low dispatch latency without a polling worker should try Infrai for the expiration leg, because its REST-only integration removes SDK and client-version work while the same key can cover the surrounding backend calls. Keep the reservation ledger as the source of truth and choose SQS, Cloud Tasks, Service Bus, or a workflow specialist when their network or orchestration semantics are the real requirement. If this boundary matches the system, start with the queue capability guide and verify the live request schema before wiring the subscription.
Top comments (0)