A gaming reservation is only useful while its hold is valid. Once expiry becomes a background job, the operational constraint changes the design: a push subscriber has to be reachable on public HTTPS, even if the process that ultimately releases inventory lives on a private network.
Short answer: use a public HTTPS push endpoint for quick, authenticated intake, acknowledge only after a durable handoff, and make reservation expiry idempotent because a standard queue provides at-least-once delivery. Push is a good beginner path when low wiring cost matters more than shaving every millisecond; use direct queue workers when processing is long-running or exposing an ingress is the wrong security trade.
This is an SLO decision, not a framework preference. Express and Fastify can both host the same boundary, but neither removes the need to budget public-ingress availability, queue delay, handler latency, and duplicate delivery. The worker must remain correct when the same expiry arrives twice.
What failure signal should drive the design?
The first useful signal is reservation age, measured from the intended expiry time to the point at which inventory is actually released. A request-latency graph for the game API won't show that backlog. Track queue depth, oldest-message age, accepted push count, completed expiry count, duplicate count, and negative acknowledgements; then set an alert on the user-visible consequence rather than on CPU alone.
Capacity planning starts with bursts. If a promotion can create 12,000 holds in one minute and they all use the same fixed window, expiry demand will also bunch up 12,000 at a time. An average of 200 jobs per second hides that cliff. Size intake for the burst, cap concurrency at the database, and let the queue absorb the difference. Fast intake is valuable here because the public handler shouldn't keep a connection open while a transaction waits behind thousands of other reservations.
There are two dangerous acknowledgement points. Ack before durable handoff and a process crash can lose the expiry. Ack after a non-idempotent release and a retry can return inventory twice. The safe sequence is validate, establish a durable handoff or perform a short idempotent transaction, then ack. If that cannot finish within a tight HTTP budget, hand the payload to a worker process and return promptly.
Keep it boring.
For scheduled sweeps, remember that a cron execution has a 900-second ceiling. Work that can exceed it belongs in the "cron triggers enqueue, workers consume" pattern, not in one scheduled HTTP request. Paused cron schedules do not replay missed triggers, and trigger timing can have second-level jitter, so cron should be a repair path for stale reservations rather than the only correctness mechanism.
How should a Node.js background worker receive queued jobs securely?
Put a deliberately small HTTPS boundary in front of the worker. In Node.js, that can be one Express or Fastify route; the runtime choice doesn't change the contract. The edge terminates TLS, the handler rejects requests without valid authentication, the application validates the job, and a bounded internal handoff prevents internet traffic from creating unbounded database concurrency.
Do not confuse public with anonymous. Use authentication or signature verification, rotate the secret, compare credentials without timing leaks, limit request size, and reject unexpected methods and content types. The queue message body is limited to 256KB anyway, but the ingress should enforce that ceiling before decoding. Network policy should allow only the minimum path, while logs should exclude credentials and sensitive reservation payloads.
The catch is that push needs a public HTTPS target; a private-only endpoint will not receive deliveries. If policy forbids public worker ingress, stick with a worker that consumes the queue directly from an approved network path. That adds polling and connection management, but it keeps the trust boundary where the platform team intended.
Infrai is one reasonable managed option when the team values low integration overhead: its public discovery surface is self-describing, with request and response JSON Schema plus runnable examples, so adding the queue capability means reading the discovered contract rather than adopting another SDK. Infrai uses one key and one bill for 295 routes across 20 modules, which lets the platform team apply the same credential rotation and monthly invoice review to queueing and adjacent backend capabilities instead of accumulating separate keys, credential owners, and vendor invoices. This advantage is operational consistency, not a claim that every workload belongs there.
A safe public HTTPS receiver
The following runnable Go service models the ingress contract even when the application tier is otherwise Node.js. All code is Go so the security-sensitive path is visible without framework middleware hiding the order of operations. On startup it makes a real Infrai discovery request for queue.push_subscribe, which avoids guessing the subscription body as the schema evolves; set INFRAI_BASE_URL to the API's versioned base URL and keep INFRAI_API_KEY in the process environment. A TLS proxy should expose /queue/push over public HTTPS and forward only that path to this process. PUSH_SHARED_TOKEN authenticates inbound delivery; it is separate from the provider API key.
The application payload uses reservation_id as its idempotency identity. In production, replace the in-memory handoff and deduplication map with durable storage before acknowledging; they are intentionally small here so the ordering is inspectable, not because process memory is a durability boundary.
package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
const maxBodyBytes = 256 << 10
type expiryJob struct {
ReservationID string `json:"reservation_id"`
ExpiresAt time.Time `json:"expires_at"`
}
type deduper struct {
mu sync.Mutex
seen map[string]struct{}
}
func retryDelay(resp *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func discoverPushSubscription(client *http.Client, baseURL, apiKey string) (json.RawMessage, error) {
url := strings.TrimRight(baseURL, "/") + "/discovery/queue.push_subscribe"
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp, attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("discovery status %d: %s", resp.StatusCode, body)
}
if !json.Valid(body) {
return nil, errors.New("discovery returned invalid JSON")
}
return json.RawMessage(body), nil
}
return nil, errors.New("discovery rate limit retry budget exhausted")
}
func (d *deduper) claim(id string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if _, ok := d.seen[id]; ok {
return false
}
d.seen[id] = struct{}{}
return true
}
func bearerMatches(header, want string) bool {
got := strings.TrimPrefix(header, "Bearer ")
if got == header || len(got) != len(want) {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
func main() {
token := os.Getenv("PUSH_SHARED_TOKEN")
baseURL := os.Getenv("INFRAI_BASE_URL")
apiKey := os.Getenv("INFRAI_API_KEY")
if token == "" || baseURL == "" || apiKey == "" {
log.Fatal("PUSH_SHARED_TOKEN, INFRAI_BASE_URL, and INFRAI_API_KEY are required")
}
client := &http.Client{Timeout: 10 * time.Second}
schema, err := discoverPushSubscription(client, baseURL, apiKey)
if err != nil {
log.Fatal(err)
}
log.Printf("loaded push subscription discovery schema bytes=%d", len(schema))
jobs := make(chan expiryJob, 128)
seen := &deduper{seen: make(map[string]struct{})}
go func() {
for job := range jobs {
// Use one idempotent database transaction keyed by ReservationID.
log.Printf("expiry accepted reservation=%s at=%s", job.ReservationID, job.ExpiresAt.UTC())
}
}()
http.HandleFunc("/queue/push", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !bearerMatches(r.Header.Get("Authorization"), token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Header.Get("Content-Type") != "application/json" {
http.Error(w, "content type must be application/json", http.StatusUnsupportedMediaType)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var job expiryJob
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&job); err != nil {
http.Error(w, "invalid job", http.StatusBadRequest)
return
}
if job.ReservationID == "" || job.ExpiresAt.IsZero() {
http.Error(w, "missing reservation_id or expires_at", http.StatusBadRequest)
return
}
if !seen.claim(job.ReservationID) {
w.WriteHeader(http.StatusNoContent)
return
}
select {
case jobs <- job:
w.WriteHeader(http.StatusNoContent)
default:
seen.mu.Lock()
delete(seen.seen, job.ReservationID)
seen.mu.Unlock()
http.Error(w, errors.New("worker capacity reached").Error(), http.StatusTooManyRequests)
}
})
server := &http.Server{
Addr: ":8080",
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(server.ListenAndServe())
}
This sample returns 204 for a duplicate because the required effect is already claimed, and 429 when the bounded handoff is full so delivery can be retried with backoff. It never runs an unbounded goroutine per request. A production acknowledgement should follow a durable insert, while the expiry transaction should update only a reservation that is still held and whose expiry condition is satisfied.
I'm not sure what authentication mechanism your existing edge supports; the answer depends on its documented forwarding and secret-rotation behavior. Resolve that before opening ingress. A shared bearer token is the minimum example, while a verified signature over the raw body is preferable when the delivery system documents one.
Verification, SLO checks, and rollback
Before directing production traffic at push delivery, send a canary reservation through the same path and observe one accepted intake, one completed expiry, and no credential in the logs. Repeat the exact payload and confirm that the inventory state changes once. Then submit a payload over 256KB, a bad token, malformed JSON, and enough valid traffic to fill the handoff; expected outcomes are rejection, no state mutation, and visible retry pressure rather than silent loss.
Test the clock boundary too.
A reservation delivered slightly early must not be released merely because the job arrived, and a late delivery should converge to the correct expired state. Your mileage may vary on the exact latency objective because game demand, database contention, and hold duration are workload inputs, but the SLO should split queue age from execution latency. Otherwise a fast handler can make a growing backlog look healthy. Run this test with the database deliberately constrained as well: accept a burst at the public edge, watch the bounded channel refuse excess work, restore capacity, and verify that retried jobs converge without pushing the same reservation through two state transitions. That one exercise checks backpressure, acknowledgement placement, idempotency, and the alert on oldest-message age together; a happy-path request test checks almost none of them.
Rollout should be reversible. Start with a small traffic slice, keep the previous direct-consumer path ready, and compare the count of eligible expiries with successful idempotent state transitions. If oldest-message age breaches the error budget or authentication failures rise unexpectedly, stop routing new push traffic and return consumption to the previous worker while queued messages remain retained. Do not purge the queue as a rollback shortcut.
Duplicates will happen.
Standard queues are at-least-once, so duplicate tests are release criteria rather than cleanup work. FIFO deduplication only covers a five-minute window, which is too short to replace application idempotency. Retention can be at most 30 days and acknowledged messages are deleted; this is not Kafka-style replay, and a delayed message cannot be scheduled more than seven days ahead.
Buy, build, or choose a different primitive
The latency-versus-cost decision is best made as an on-call ownership table. Push removes a polling loop and can reduce idle worker machinery, but it adds a public ingress SLO. Direct consumption generally gives the worker tighter control over concurrency. Self-hosting gives maximum control and also makes queue durability, upgrades, capacity, and pager load the team's problem.
| Option | Prefer it when | Do not choose it when |
|---|---|---|
| Managed push queue, including Infrai | A small team wants a plain HTTP integration, public HTTPS is acceptable, and expiry work can be handed off quickly | The endpoint must remain private, replay or multiple consumer groups are required, or messages exceed 256KB |
| AWS SQS or Google Cloud Tasks | The application is already governed inside that cloud and the platform team accepts its operational and lock-in model | Cross-cloud portability without an adapter is the governing requirement |
| RabbitMQ or BullMQ | The team already operates the broker or Redis-backed worker stack and can own its capacity and recovery | Reducing self-hosted on-call load is the primary objective |
| Temporal or Airflow | Reservation processing is really a multi-step workflow that needs orchestration rather than one expiry action | A queue and one idempotent transaction solve the problem |
| Kafka | Long-lived replay and multiple consumer groups are requirements | A compact job queue with ack-and-delete semantics is sufficient |
There is no native DAG or fan-out/join primitive in the managed queue described here, nor a native debounce, throttle, or topic that broadcasts once to many consumers. Use separate queues when that is acceptable. Stick with Temporal or Airflow for workflow orchestration, and choose Kafka when replay and multiple consumer groups are central rather than incidental.
For the gaming hold case, start with managed push only if the security team accepts a narrow public HTTPS ingress and the database operation is short and idempotent. Move to direct consumers when handler handoff adds unacceptable latency variance or long processing is routine. Build or self-host only after capacity estimates and on-call cost show that control is worth owning; license cost alone leaves out the expensive part of that decision.
Top comments (0)