Skip to content
imessageapi

Scheduling messages across time zones without waking anyone up

Every reminder system is a scheduling system underneath, and time zones are where they fail. Usually at 5am, in someone else's country.

8 min readUpdated August 23, 2026Getting started

A reminder that arrives at the right moment is useful. The same reminder three hours early is an intrusion, and three hours late is worthless. Getting this right is mostly about refusing to store times in the wrong shape.

Store the instant and the zone, never the local string

The bug that causes almost all of these failures is storing a wall-clock time without the zone it belongs to. Store an absolute instant plus the IANA zone name, and derive local time when you need it.

schema
type ScheduledMessage = {
id: string;
recipient: string;
body: string;
sendAt: string; // ISO 8601, UTC — the absolute instant
timeZone: string; // "America/Chicago" — needed for quiet hours
status: "pending" | "sent" | "held" | "cancelled";
attempts: number;
};
 
// Never store "2026-09-14 09:00". That is not a time, it is 24 times.

Daylight saving will break a naive schedule

A reminder scheduled 24 hours before an appointment crossing a DST boundary drifts by an hour if you do the arithmetic in local time. Compute in UTC, render in local, and your reminders stay correct through the shift.

Derive the zone when you do not have it

Most small businesses do not ask customers for a time zone, and should not start. Derive it: from the appointment location if you have one, from the area code as a fallback, and from your own business zone as a last resort. Record which method you used so you can tell a good guess from a bad one.

resolve-zone.ts
export function resolveTimeZone(customer: Customer, appointment?: Appointment) {
// Best: where the appointment physically is.
if (appointment?.locationTimeZone) {
return { zone: appointment.locationTimeZone, source: "location" as const };
}
// Good: area code is a decent proxy for a domestic list.
const fromArea = zoneForAreaCode(customer.phone);
if (fromArea) return { zone: fromArea, source: "area_code" as const };
 
// Fallback: your own zone. Log it — a pile of these means you should ask.
return { zone: process.env.BUSINESS_TIME_ZONE!, source: "default" as const };
}

Hold, do not drop

When a message falls outside the send window, move it to held and re-queue for the next open slot. Dropping it silently means a customer misses an appointment because of your compliance logic, which is the worst possible way to be compliant.

worker.ts
export async function drainQueue(now: Date) {
const due = await pendingMessagesDueBy(now);
 
for (const msg of due) {
if (!withinSendWindow(now, msg.timeZone)) {
await hold(msg, nextWindowOpen(now, msg.timeZone));
continue;
}
 
// Late is sometimes worse than never — a reminder for an appointment
// that already started is noise.
if (msg.expiresAt && now > new Date(msg.expiresAt)) {
await cancel(msg, "expired");
continue;
}
 
await sendWithRetry(msg);
}
}

Give scheduled messages an expiry

If your queue stalls overnight and drains at 9am, you do not want yesterday's reminders going out. Every scheduled message should carry the point past which sending it is worse than not sending it, and the worker should honour it.

Make cancellation cascade

When an appointment is cancelled or moved, every queued message attached to it must be cancelled too. The reminder for an appointment the customer already cancelled is the most common complaint in this whole category, and it is always a missing cascade.

The frequency caps also belong in this layer rather than in each campaign — quiet hours and frequency explains why enforcing centrally is the only version that holds.

Next step

Generate a tagged link for whatever you send next with the UTM builder, see what this looks like in your industry, or compare the services that can send it on the providers page.