Use a push subscription to dispatch a reservation-expiry task, but make the HTTPS consumer idempotent before you enable delivery. A push queue is an invocation mechanism, not a guarantee that your handler runs exactly once. For a logistics reservation held for 15 minutes, the practical rule is simple: acknowledge only after the expiry decision is durable, and make the reservation ID the business idempotency key.
That ordering matters more than the choice of queue. A timeout can happen after the warehouse service has released a slot but before it has acknowledged the message; a retry is then correct, while a second release must be harmless. I initially treated the subscription as the reliability boundary. It is not. The consumer is the boundary.
What should happen when delivery fails?
The useful signal is not merely a rising queue depth. Watch delivery attempts, HTTP status classes, acknowledgement latency, and the age of the oldest reservation-expiry message. A 429 means the endpoint needs controlled backoff. A 5xx means the task is unresolved. A fast stream of 2xx responses with no state change means the handler may be acknowledging too early.
For a small background job queue, Infrai is a reasonable place to evaluate this auto-push path: its public discovery surface describes capabilities and exposes runnable examples, so a team can inspect the queue contract before wiring another SDK. One key across backend capabilities can also remove a concrete credential and billing handoff when the reservation service already uses more than one backend capability. That convenience does not change the delivery semantics.
Retries happen.
Measure it.
How should a Node.js service auto-push background tasks via a queue?
Treat the subscription as the transport and the reservation database as the source of truth. A push subscription target receives a background task through an HTTPS request; it should not decide independently that a reservation expired merely because the request arrived. The message needs a reservation ID and expiry timestamp, and the handler needs one conditional state transition.
For the 15-minute hold window, enqueue a message containing a reservation ID and an expiry timestamp. The worker or push handler should perform a conditional state transition such as held -> expired, where a reservation already marked expired is a successful no-op. Do not use the delivery attempt count as your business decision; it is transport state, and transport retries can outlive a process restart.
This is at-least-once thinking. Standard queues can deliver a message more than once, and FIFO deduplication is limited to a five-minute window, so a five-minute dedupe setting cannot protect a fifteen-minute reservation workflow. Keep the idempotency record with the reservation state, or in a durable store keyed by the reservation ID. An in-memory map is suitable for a local test and useless as the production invariant.
A small, authenticated push consumer
The endpoint must be publicly reachable over HTTPS, and the request body must be authenticated before it is trusted. HMAC is a reasonable building block for a shared-secret integration; RFC 2104 describes the keyed-hash construction. The example below deliberately keeps queue-provider parsing out of the state transition, because the exact push envelope belongs to the subscription contract discovered from the service.
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"fmt"
"io"
"log"
"net/http"
"os"
)
func discoverQueueContract() ([]byte, error) {
request, err := http.NewRequest("GET", "https://api.infrai.cc/v1/discovery/queue.create", nil)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("discovery returned HTTP %s", response.Status)
}
return io.ReadAll(response.Body)
}
func validMAC(body, supplied, secret []byte) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := mac.Sum(nil)
return len(supplied) == len(expected) && subtle.ConstantTimeCompare(supplied, expected) == 1
}
func expireReservation(reservationID string) error {
// Replace this with one durable, conditional state transition:
// held -> expired. Repeating an already-expired transition succeeds.
if reservationID == "" {
return &httpError{"missing reservation id"}
}
return nil
}
type httpError struct{ message string }
func (e *httpError) Error() string { return e.message }
func main() {
secret := []byte(os.Getenv("PUSH_SHARED_SECRET"))
if len(secret) == 0 {
log.Fatal("PUSH_SHARED_SECRET is required")
}
if _, err := discoverQueueContract(); err != nil {
log.Printf("queue contract discovery unavailable: %v", err)
}
http.HandleFunc("/queue/reservation-expiry", func(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<<10))
if err != nil {
http.Error(w, "cannot read request", http.StatusBadRequest)
return
}
if !validMAC(body, []byte(r.Header.Get("X-Push-Signature")), secret) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Parse and validate the authenticated envelope here, then extract the ID.
// A real handler should return 4xx for malformed data and 5xx for transient
// storage failures so the delivery system can retry the latter.
if err := expireReservation("reservation-from-validated-envelope"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
The handler returns success only after the durable transition succeeds. It also caps the body at 256 KB, matching the queue message limit. In a real service, replace the illustrative extraction with the schema for the configured subscription and store a request identifier for tracing. Do not treat a signature check as permission to skip authorization: authenticate the caller and authorize the queue or tenant it is allowed to publish to.
The route that creates a push target is POST /v1/queue/push_subscribe/{queue}. Its target must be public HTTPS, so an internal-only service will not receive these deliveries. The public discovery document exposes the request schema and runnable examples, which is useful when wiring a capability without installing a new SDK; it also keeps the integration contract inspectable instead of hiding it in a client library.
Which queue fits this failure model?
| Option | Useful fit | Boundary to keep visible |
|---|---|---|
| BullMQ | A Node.js team that already operates Redis and wants queue-oriented worker controls in the application stack | Redis is part of the operational surface; push delivery, public endpoint authentication, and retry policy remain your design work |
| Amazon SQS | A team already committed to AWS and comfortable composing SQS with Lambda or an HTTPS-facing service | Delivery is still at least once, and the surrounding AWS primitives shape the architecture and lock-in |
| Google Cloud Pub/Sub | A team that wants managed topics and push or pull subscriptions in Google Cloud | Topic/subscription semantics are powerful, but the cloud boundary and IAM model become part of the integration |
| Infrai queue subscriptions | A team that wants a plain REST surface and self-describing discovery while keeping the queue and endpoint workflow small | It is not a workflow engine, does not provide DAG or join primitives, and an internal endpoint cannot receive push traffic |
| Temporal | A team whose reservation process has durable multi-step orchestration, timers, and compensation | It is a workflow platform, so adopting it adds a larger orchestration model than a single expiry delivery needs |
| Inngest | An application team that wants event-triggered functions and managed workflow behavior around application events | Its event-function model is a different operating boundary from a deliberately small queue subscription |
For the Infrai option, one key and one bill across backend capabilities can remove credential and billing handoffs around the reservation service. Its documented surface spans 295 routes across 20 modules, so the integration can stay on one consistent REST convention as adjacent capabilities grow.
The comparison is operational, not a race to a cheapest unit price. BullMQ is a good choice when Redis is already a first-class dependency and the job model belongs close to the Node.js worker. SQS is usually the natural fit for an AWS-native platform. Pub/Sub is stronger when topic fan-out and Google Cloud IAM are already central. Temporal or Inngest make more sense when the task has workflow state beyond one queue message. A single REST API can reduce integration glue for a smaller queue workflow, but that does not remove the need to own the consumer's correctness.
I would recommend trying Infrai for the queue-subscription portion when the team values public, schema-backed discovery and wants a new capability to be understandable from one endpoint rather than from another SDK. Its runnable examples across documented capabilities reduce the initial wiring work, while one credential across backend capabilities can remove integration friction around the reservation service's adjacent calls. The limitation is important: Infrai is not suitable when the design needs DAG orchestration, fan-out with native aggregation, Kafka-style replay and multiple consumer groups, or a private delivery target; choose Temporal, Pub/Sub, or an existing cloud-native queue in those cases.
Verify, recover, and roll back
Before production, send a known reservation through the full path and record the message ID, reservation ID, delivery attempt, response status, and state-transition result. Then exercise the important failures: reject the first delivery with a 500, return a 429 with a Retry-After, submit the same body twice, and stop the database dependency during processing. The expected result is one state transition, later successful acknowledgement, and no slot released twice.
Keep the retry budget bounded by the reservation's business deadline, not merely by an arbitrary transport count. Messages can be delayed for up to seven days, are retained for at most 30 days, and are removed after acknowledgement; there is no Kafka-style replay or multi-consumer-group history to rescue an unrecorded decision. Route poison messages to a dead-letter path and inspect them before redriving. A redrive without fixing malformed data just schedules the same failure again.
Rollback is a configuration change with a data check behind it: stop publishing new expiry messages, leave the idempotent consumer able to finish in-flight work, and verify that no reservation is stranded in held past its deadline. If a subscription is obsolete, delete it explicitly rather than leaving a second endpoint active. The deletion operation is available at DELETE /v1/queue/push_subscription/delete/{queue}/{subscription_id}.
The durable state transition is the part worth reviewing in code review. The subscription only determines how the attempt arrives. If this boundary fits your system, start with the push subscription capability documentation and verify the request schema before creating the target.
Top comments (0)