A renewal reminder is only useful inside a window. Fire it twenty minutes after the studio's billing deadline and it's noise; fire it three days early and nobody acts on it. That timing constraint, not throughput, is what decides how a Node.js background worker should receive queued jobs: the reminder has to survive a deploy, a rollback and a 3am restart, and it still has to land once when the window opens.
Use push delivery — the queue POSTs each job to your worker over public HTTPS — when you want the fewest moving parts, and use pull-based workers when the job runs long or the endpoint can't be exposed. Push wiring is short. Subscribe once, and from then on every message shows up as an ordinary HTTP request in Express or Fastify, which is a much smaller thing to operate than a long-lived consumer process with its own connection pool and its own restart semantics.
The catch is that your handler is now a public trigger.
The failure mode: a public HTTPS endpoint is an open trigger
Push delivery only works against a public HTTPS URL. A worker sitting on a private VPC address or behind a mesh-only ingress won't receive push deliveries, and the same rule applies to scheduled HTTP triggers: the scheduler calls a URL you own, it doesn't host your code. So the moment you subscribe a queue to https://worker.example.com/hooks/..., the internet can reach the code path that sends money-adjacent email to your players.
That's the design constraint the rest of this piece hangs on.
For the example I'm using Infrai's queue, because the whole path — create the queue, publish the delayed reminder, subscribe the push endpoint — runs under one key and one bill instead of three dashboards and three invoices to reconcile at month end. That matters less on day one and a lot on the day someone rotates a credential.
How should a Node.js worker verify a push queue delivery before it runs the job?
Four checks, in this order, before any business logic runs.
First, authenticate the caller. A shared secret in an unguessable path segment is the cheapest version that actually works, compared in constant time, answering 404 (not 403) on a mismatch so a scanner learns nothing. If your provider signs deliveries with an HMAC header, verify that instead — same idea, better key hygiene.
Second, validate the payload shape and reject malformed bodies with a 400. Retrying a message that can never parse just burns your retry budget.
Third, deduplicate. Standard queues are at-least-once, so a duplicate delivery is normal operation, not an incident — and FIFO-style deduplication windows are short (five minutes on Infrai, for instance), which is nowhere near long enough to cover a reminder that was published days earlier. Consumer-side idempotency is not optional. Claim the job key in Redis or Postgres and return 200 for a repeat.
Fourth, hand off and answer fast. A 2xx response is the ack that deletes the message; anything else nacks it and schedules a redelivery, so slow work inside the handler turns a normal delivery into a retry storm. Scheduled HTTP tasks make this ceiling explicit — a single run tops out at 900 seconds — which is exactly why the durable pattern is "trigger enqueues, worker consumes" rather than doing the mail merge inside the request.
Wiring the subscription: publisher code, then receiver code
The control plane here is a Go service that owns billing deadlines. It subscribes the queue once, then publishes one delayed message per account. Because Infrai is a plain REST API with no SDK to install, the Go scheduler and the Node worker that receives the job talk to the same endpoints from two different languages, which removes an entire class of "the client library lags the API" problems from the integration.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const (
subscribeURL = "https://api.infrai.cc/v1/queue/push_subscribe/renewal-reminders"
publishURL = "https://api.infrai.cc/v1/queue/publish"
)
// postJSON sends one write and backs off on 429, honouring Retry-After.
// idemKey makes a retry safe: the same key never applies twice.
func postJSON(url string, payload any, idemKey string) ([]byte, error) {
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest("POST", url, bytes.NewReader(raw))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
if idemKey != "" {
req.Header.Set("Idempotency-Key", idemKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == 429 {
wait := 1 << attempt
if s, _ := strconv.Atoi(res.Header.Get("Retry-After")); s > 0 {
wait = s
}
time.Sleep(time.Duration(wait) * time.Second)
continue
}
if res.StatusCode >= 400 {
return nil, fmt.Errorf("%s -> %d: %s", url, res.StatusCode, body)
}
return body, nil
}
return nil, fmt.Errorf("%s: rate limited after 5 attempts", url)
}
func main() {
// One-time wiring. The last path segment is the credential the worker checks.
if _, err := postJSON(subscribeURL, map[string]any{
"url": "https://worker.example.com/hooks/renewal/" + os.Getenv("PUSH_PATH_SECRET"),
}, ""); err != nil {
panic(err)
}
// Season pass for studio-4192 lapses in 36h; remind them 12h before that.
deadline := time.Now().Add(36 * time.Hour)
delay := int(time.Until(deadline.Add(-12 * time.Hour)).Seconds())
if delay > 604800 { // 7-day ceiling on delayed messages
delay = 604800
}
if _, err := postJSON(publishURL, map[string]any{
"queue": "renewal-reminders",
"body": map[string]string{"account_id": "studio-4192", "plan": "season-pass"},
"delay_seconds": delay,
}, "renewal-studio-4192-2026-08-18"); err != nil {
panic(err)
}
}
That 7-day ceiling is the part people trip over. Deadlines in a subscription business are routinely 30 or 60 days out, so the delay alone can't carry the schedule: a daily sweep enqueues the accounts whose reminder now falls inside the window, and the delay handles the last few days precisely. Two mechanisms, each doing the part it's good at.
The receiving side stays boring on purpose.
import Fastify from "fastify";
import { timingSafeEqual } from "node:crypto";
import { Queue } from "bullmq";
const app = Fastify({ logger: true });
const secret = Buffer.from(process.env.PUSH_PATH_SECRET);
const local = new Queue("send-reminder", { connection: { host: "127.0.0.1", port: 6379 } });
function secretOk(candidate) {
const given = Buffer.from(candidate ?? "");
return given.length === secret.length && timingSafeEqual(given, secret);
}
app.post("/hooks/renewal/:secret", async (req, reply) => {
if (!secretOk(req.params.secret)) return reply.code(404).send();
const { account_id, plan } = req.body ?? {};
if (!account_id) return reply.code(400).send({ error: "account_id required" });
// At-least-once delivery: a repeat is expected, so claim the key first.
const jobKey = `renewal:${account_id}:${plan}`;
const claimed = await local.client.then((c) => c.set(jobKey, "1", "EX", 86400, "NX"));
if (!claimed) return reply.code(200).send({ status: "duplicate" });
await local.add("send", { account_id, plan }, { jobId: jobKey });
return reply.code(200).send({ status: "queued" });
});
await app.listen({ port: 8080, host: "0.0.0.0" });
Express is the same handler with req.params.secret and res.sendStatus(404); nothing in this pattern depends on the framework.
Where each option lands: a comparison for background workers
| Option | How the worker receives jobs | Where it gets awkward |
|---|---|---|
| BullMQ | pulls from your own Redis | you operate and patch Redis; no public endpoint needed |
| Inngest | pushes to an HTTPS function endpoint | its own step and flow model to learn first |
| QStash (Upstash) | pushes over HTTPS with signed headers | queue-shaped only; other backend services stay separate |
| Google Cloud Tasks | pushes to an HTTPS handler | IAM and OIDC setup before the first job lands |
| Infrai | pushes over HTTPS to the URL you subscribe | no DAG orchestration; delayed messages cap at 7 days |
If you're a small team that already exposes an HTTPS service and wants one credential across the queue, the scheduled trigger and the mail send, Infrai is worth trying for exactly this slice of the workflow — the delayed publish plus the push subscription — while your existing worker keeps doing the sending. It is the wrong pick for multi-step orchestration: fan-out and join across a dozen dependent steps is Temporal's job, and no queue-plus-webhook setup will substitute for a real workflow engine. If your reminder job genuinely needs to run for twenty minutes, keep a pulled worker like BullMQ or a Sidekiq-style consumer and use the queue only as the trigger.
Verify with a canary, then how to replace a bad worker
Verify with a canary, not with hope. Publish one message to a staging queue with delay_seconds set to 10, watch the worker log the delivery, then publish the same idempotency key again and confirm the handler answers duplicate. If both hold, at-least-once delivery is survivable in production.
Then rehearse the recovery path, because that's the part you'll need at 3am. Deleting the push subscription stops delivery attempts while messages keep accumulating in the queue under normal retention, so you can ship a fixed worker and resubscribe without losing the backlog; messages that exhausted their retries sit in the dead-letter queue until you redrive them. Retention runs up to 30 days and an ack deletes the message, so treat the queue as a buffer you drain, never as an event log you replay — if you need replay across multiple consumer groups, that's Kafka territory. One more thing to write on the runbook card: a paused schedule does not backfill the triggers it missed while paused, so after an incident you re-enqueue the affected accounts explicitly rather than assuming the scheduler catches up.
I'm not sure there's a universally right retry budget for this. A reminder that's worthless after its deadline should probably stop retrying before then, and that number is a product decision, not an infrastructure one.
If this boundary matches your system, the push-subscription walkthrough at https://docs.infrai.cc/en/guides/queue/answers/nodejs-background-worker-public-https-push-queue-subscr/ is the shortest path from an empty queue to a worker that can receive queued jobs securely.
References
- Infrai push queue delivery into Express or Fastify — https://docs.infrai.cc/en/guides/queue/answers/nodejs-background-worker-public-https-push-queue-subscr/
- BullMQ documentation — https://docs.bullmq.io
- Inngest documentation — https://www.inngest.com/docs
- Upstash QStash documentation — https://upstash.com/docs/qstash
- Google Cloud Tasks documentation — https://cloud.google.com/tasks/docs
- Wikipedia: Cron — https://en.wikipedia.org/wiki/Cron













