Timezone-correct user reminders with a cron sweep and delayed queue messages

Send 9am-local reminders without one cron job per timezone: sweep on a coarse schedule, compute the next local occurrence, publish with a per-message delay.

“Remind every user at 9am their time” is not a cron expression. It’s roughly 38 of them, and they all break twice a year at daylight-saving boundaries. The arrangement that holds up is a coarse sweep plus a fine delay: a job every 15 minutes finds reminders due in the next window, computes each one’s exact UTC instant from the user’s IANA zone, and publishes it to an Infrai queue with a per-message delay of the seconds in between.

The queue does the waiting. Your scheduler only ever runs one job, on one schedule, in UTC.

The sweep window

Pick a window a little longer than your sweep interval so a slow run can’t drop a reminder. Every 15 minutes, look 20 minutes ahead; the overlap gets deduplicated downstream by the reminder’s occurrence key.

Delays are honoured up to seven days — we published at 604,800 seconds successfully on 2026-07-26 and got a rejection above that — but keeping them under an hour has a practical advantage. A delayed message can’t be cancelled or rescheduled; there’s no route for it. If a user turns a reminder off ninety seconds after your sweep published it, the message is still coming, and the only defence is that your consumer re-checks the reminder’s state before it sends. Short delays mean a smaller window of stale intent.

Working out “9am in Sao Paulo”

Intl.DateTimeFormat already carries the whole tz database. You don’t need a date library for this.

export function zoneParts(instant, timeZone) {
  const dtf = new Intl.DateTimeFormat("en-US", {
    timeZone, hour12: false,
    year: "numeric", month: "2-digit", day: "2-digit",
    hour: "2-digit", minute: "2-digit", second: "2-digit",
  });
  const p = Object.fromEntries(dtf.formatToParts(instant).map((x) => [x.type, x.value]));
  return { year: +p.year, month: +p.month, day: +p.day, hour: +p.hour % 24, minute: +p.minute, second: +p.second };
}

function offsetMs(instant, timeZone) {
  const p = zoneParts(instant, timeZone);
  return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - instant.getTime() + instant.getMilliseconds();
}

export function nextLocalOccurrence(timeZone, hour, minute, from = new Date()) {
  const today = zoneParts(from, timeZone);
  for (const addDays of [0, 1, 2]) {
    const wall = () => Date.UTC(today.year, today.month - 1, today.day + addDays, hour, minute, 0);
    let utc = wall();
    for (let pass = 0; pass < 2; pass++) utc = wall() - offsetMs(new Date(utc), timeZone);
    if (utc > from.getTime()) return new Date(utc);
  }
  throw new Error(`no upcoming ${hour}:${minute} for ${timeZone}`);
}

const noon = new Date("2026-07-26T01:20:00Z");
for (const tz of ["Asia/Tokyo", "America/New_York", "Europe/Berlin", "Asia/Kolkata"]) {
  console.log(tz, nextLocalOccurrence(tz, 9, 0, noon).toISOString());
}

The two-pass loop is the DST fix. A first guess assumes today’s offset applies at the target instant; if the clock shifts in between, the second pass corrects it. Run that block on Node 22 and America/New_York resolves to 2026-07-26T13:00:00.000Z — 09:00 EDT, four hours behind UTC — while the same call for a March morning after the spring-forward lands on 13:00Z too, because the code re-reads the offset rather than assuming it.

The sweep publisher

import process from "node:process";
import { nextLocalOccurrence } from "./timezone.mjs";

const BASE = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");
const QUEUE = "user-reminders";
const WINDOW_MS = 20 * 60 * 1000;
const MAX_DELAY_SECONDS = 7 * 24 * 3600;

async function schedule(reminder, fireAt, now) {
  const delay = Math.min(MAX_DELAY_SECONDS, Math.max(0, Math.round((fireAt.getTime() - now.getTime()) / 1000)));
  const message = {
    queue: QUEUE,
    body: {
      reminder_id: reminder.id,
      user_id: reminder.user_id,
      channel: reminder.channel,
      occurrence: fireAt.toISOString(),
    },
  };
  if (delay > 0) message.delay_seconds = delay;

  const res = await fetch(`${BASE}/v1/queue/publish`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify(message),
  });
  const out = await res.json();
  if (!res.ok || out.ok === false) throw new Error(`publish ${reminder.id}: ${out.error?.code ?? res.status}`);
  return { message_id: out.data.message_id, delay };
}

export async function sweep(reminders, now = new Date()) {
  const horizon = new Date(now.getTime() + WINDOW_MS);
  let queued = 0;
  for (const reminder of reminders) {
    const fireAt = nextLocalOccurrence(reminder.timezone, reminder.hour, reminder.minute, now);
    if (fireAt > horizon) continue;
    const { message_id: id, delay } = await schedule(reminder, fireAt, now);
    console.log(`${reminder.id} -> ${fireAt.toISOString()} (+${delay}s) as ${id}`);
    queued++;
  }
  return queued;
}

const demo = [{ id: "rem_1", user_id: "u_9", channel: "email", timezone: "Asia/Kolkata", hour: 9, minute: 0 }];
console.log(`queued ${await sweep(demo)} reminders`);

Note the delay goes into the message object conditionally rather than always — a reminder already due gets published for immediate delivery, and there’s no sense sending 0.

Trust the stats, not the publish echo

Here’s a genuine trap. Publish a delayed message and the response still reports "status": "available", which reads like the message is ready. It isn’t; consume right afterwards and you get an empty items array. The queue’s counters tell the truth:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/queue/stats/user-reminders" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "queue": "user-reminders",
    "message_count": 0,
    "available_count": 0,
    "in_flight_count": 0,
    "delayed_count": 3,
    "dlq_count": 0,
    "oldest_message_age_seconds": 0
  }
}

A delay outside the accepted range comes back as QUEUE_DELAY_INVALID, which is why the publisher above clamps rather than trusting arithmetic on user-supplied data. Worth flagging that some out-of-range rejections we hit reported a confusing “queue already exists” message instead — check the HTTP status, not the prose.

Pull or push?

The reminders have to reach something that sends. Two shapes, and the choice is mostly about whether you have a public HTTPS endpoint.

Polling workerPush subscription
Public HTTPS endpoint neededNoYes
Delivery latency after the delay expiresYour poll intervalAbout 5 seconds in our testing
Concurrency controlYours — max_messages up to 10Fixed at 10 in-flight
Failure handlingDon’t ack, it comes back3 attempts about 6s apart, then DLQ
Runs behind a VPN or on a laptopYesNo

POST /v1/queue/push_subscribe/{queue} registers the webhook form; check the subscription body against the queue API reference before you wire it, and note there’s no unsubscribe route, so treat registration as close to permanent. For most reminder backends the polling worker is simpler and it’s what the rest of this example uses.

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"user-reminders","max_messages":10}'

The sender re-checks before it sends

import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";

const BASE = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
const QUEUE = "user-reminders";

const active = new Map([["rem_1", { channel: "email", enabled: true }]]);

async function call(path, payload) {
  const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (out.ok === false) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
  return out.data;
}

async function deliver(job) {
  const state = active.get(job.reminder_id);
  if (!state?.enabled) {
    console.log(`${job.reminder_id} was disabled after scheduling — dropping`);
    return;
  }
  console.log(`sending ${state.channel} reminder ${job.reminder_id} to ${job.user_id} for ${job.occurrence}`);
}

let running = true;
process.on("SIGTERM", () => { running = false; });

while (running) {
  const { items } = await call("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
  if (items.length === 0) { await sleep(3000); continue; }
  for (const msg of items) {
    try {
      await deliver(msg.payload);
      await call("/v1/queue/ack", { queue: QUEUE, receipt_handle: msg.message_id });
    } catch (err) {
      console.error(`${msg.payload.reminder_id} delivery ${msg.delivery_count} failed: ${err.message}`);
    }
  }
}

The state re-check is not optional. A queue that can’t cancel a scheduled message pushes that responsibility onto the consumer, and this is the whole of it: three lines and a lookup.

Sending the email or the SMS itself is the next call, and it’s on the same credential — no second vendor account, no second invoice, no second key to rotate when someone leaves.

Cost

Publishing is the only metered call. It’s $0.00002 per message, verified 2026-07-26, and consume, ack, stats and DLQ reads are free but rate-limited. A daily reminder for 50,000 users is 50,000 publishes a day — $1 a day, or $30 a month. Sweeping every 15 minutes doesn’t change that; you publish once per reminder occurrence, not once per sweep.

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

That’s the real figure for your account. Rates trend downward and discount periods run, so the number you read may be lower than the one printed here. New accounts start with $2 of free credit.

Alternatives worth naming

BullMQ’s delayed jobs do the same thing with a job id you can remove — cancellation is a genuine capability gap here, and if reminders change often that alone may decide it. QStash is the HTTP-native option if you’d rather the schedule itself be the managed thing and your app just receive webhooks. And if all you need is a fixed UTC schedule with no per-user times, node-cron in a small always-on process is less machinery than any of this.

Where this arrangement earns its place: one schedule instead of one per timezone, delays that survive a worker restart because they live in the queue rather than in a timer, and the send itself already reachable on the same key.

References

Browse more queue developer guides