· 6 min read · postpeg team
Scheduling social posts across time zones without mistakes
Why a bare local time is ambiguous, how offsets and IANA zones differ, the DST traps in recurring schedules, and how to send scheduled_at safely.
To schedule a post for the right moment, send an exact instant: a timestamp with a UTC offset, such as 2026-10-26T09:00:00+01:00. Keep the user's IANA time zone name (for example Europe/Berlin) next to what they asked for, and work out the offset for each occurrence at the moment you schedule it. Almost every time zone bug comes from skipping one of those two steps.
Why "9:00" on its own is not a time
A value like 2026-10-26T09:00 describes a clock face, not a moment. It happens at 08:00 UTC in Berlin, 09:00 UTC in London and 13:00 UTC in New York. If your API stores it without saying where, the server has to guess, and it will usually guess its own zone.
RFC 3339, the internet profile of ISO 8601, removes the guess. Its date-time grammar always ends in a time offset: either Z for UTC or a numeric offset such as +02:00. The RFC defines the offset as local time minus UTC, so you get UTC by subtracting it: 09:00+01:00 is 08:00Z.
Offsets and time zones are different things
An offset (+01:00) is a fixed distance from UTC at one instant. A time zone (Europe/Berlin) is a set of rules that says which offset applies on which date. Berlin is +01:00 in winter and +02:00 in summer, so the offset alone cannot tell you what next month's 09:00 will be.
Those rules live in the tz database maintained by IANA, which exists to record the history of local time for representative places. It changes because governments change their rules: IANA publishes new releases as boundaries, offsets and daylight saving rules change, often several times a year. Your runtime's copy of that data is what turns a zone name into an offset, so keep it up to date.
The practical rule:
| Store | Example | Use it for |
|---|---|---|
| Instant (UTC) | 2026-10-26T08:00:00Z | When the post actually goes out |
| Intent | 09:00 every Monday | What the user asked for |
| IANA zone | Europe/Berlin | Turning the intent into the next instant |
Never store a bare offset as the user's "zone". +01:00 is Berlin, Lagos and several other places in October, and they do not share rules.
The two daylight saving traps
Clock changes create local times that either do not exist or exist twice.
The skipped hour (spring). When clocks go forward, an hour of wall-clock time never happens. In Berlin on 29 March 2026, clocks jump from 02:00 to 03:00, so 02:30 that night is not a real time.
The repeated hour (autumn). When clocks go back, an hour happens twice. In Berlin on 25 October 2026, 02:30 occurs once at +02:00 and again an hour later at +01:00.
Social posts rarely go out at 02:30, but a "post at 2:30 am to catch another continent" schedule does exist, and your code has to pick an answer. Temporal's disambiguation option makes that choice explicit: earlier, later, reject (throws a RangeError), or the default compatible, which takes the first occurrence of a repeated time and moves a skipped time forward, matching what Date does.
Countries do not switch on the same day
The EU changes clocks on the last Sunday of March and the last Sunday of October under Directive 2000/84/EC, as summarised by the European Parliament's research service. The US changes on the second Sunday of March and the first Sunday of November at 2:00 local time; NIST gives the 2026 dates as 8 March and 1 November.
So in 2026 there are two stretches where the usual gaps are wrong. From 25 October to 1 November, London is +00:00 while New York is still -04:00: four hours apart instead of five. The same happens between 8 and 29 March. If your app shows "this goes out at 14:00 London time" for a post scheduled in New York time, those are the weeks it gets it wrong unless you ask the tz data for each date.
Recurring posts: recompute every occurrence
"Every Monday at 09:00 Berlin time" is a rule in local time. Adding 7 * 24 * 60 * 60 * 1000 milliseconds to the last instant keeps the UTC time fixed, so the week after the October change your post drifts to 08:00 local.
Instead, keep the rule plus the zone, and for each occurrence build the local date and time, then convert. With Temporal, adding calendar units does exactly that:
const first = Temporal.ZonedDateTime.from('2026-10-19T09:00[Europe/Berlin]');
first.add({ weeks: 1 }).toString();
// "2026-10-26T09:00:00+01:00[Europe/Berlin]" still 09:00 local
first.add({ hours: 168 }).toString();
// "2026-10-26T08:00:00+01:00[Europe/Berlin]" drifted to 08:00MDN's page describes the same split: adding hours works on exact time, adding days keeps the wall-clock time.
Getting an offset in JavaScript today
Intl.DateTimeFormat can report the offset a zone uses at a given instant through timeZoneName: "longOffset", which MDN documents and which works in current browsers and Node 17 and later.
function offsetFor(timeZone, date) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
timeZoneName: 'longOffset',
}).formatToParts(date);
const name = parts.find((p) => p.type === 'timeZoneName').value; // "GMT+01:00" or "GMT"
return name === 'GMT' ? '+00:00' : name.slice(3);
}
offsetFor('Europe/Berlin', new Date('2026-10-26T08:00:00Z')); // "+01:00"
offsetFor('America/New_York', new Date('2026-10-26T08:00:00Z')); // "-04:00"
offsetFor('Asia/Kolkata', new Date('2026-10-26T08:00:00Z')); // "+05:30"To turn a wall-clock time into a timestamp, guess the offset, then check it against the instant it produces:
function toScheduledAt(wallClock, timeZone) {
// wallClock like "2026-10-26T09:00"
const [d, t] = wallClock.split('T');
const [y, mo, da] = d.split('-').map(Number);
const [h, mi] = t.split(':').map(Number);
const asUtc = Date.UTC(y, mo - 1, da, h, mi);
const toMs = (o) =>
(o[0] === '-' ? -1 : 1) * (Number(o.slice(1, 3)) * 60 + Number(o.slice(4, 6))) * 60000;
let offset = offsetFor(timeZone, new Date(asUtc));
const checked = offsetFor(timeZone, new Date(asUtc - toMs(offset)));
if (checked !== offset) offset = checked;
return `${wallClock}:00${offset}`;
}
toScheduledAt('2026-10-23T09:00', 'Europe/Berlin'); // "2026-10-23T09:00:00+02:00"
toScheduledAt('2026-10-26T09:00', 'Europe/Berlin'); // "2026-10-26T09:00:00+01:00"This is right for ordinary times. It does not decide what a skipped or repeated time should mean, so if users can pick times in the early hours, validate those cases or use Temporal with an explicit disambiguation.
Can you use Temporal yet?
Partly. The Temporal proposal has reached Stage 4 and is being merged into the language specification. MDN's compatibility data lists it in Firefox 139, Chrome and Edge 144, Deno 2.7 and Node.js 26, while Safari has it only in Technology Preview, so MDN still marks Temporal as not Baseline. On the server with Node 26 you can use it directly. In the browser, use a polyfill or feature-detect with typeof Temporal.
Convert at the edge
The safest shape for a scheduling system:
- The user picks a date and time in their own zone. You record the IANA zone from the browser (
Intl.DateTimeFormat().resolvedOptions().timeZone) or their profile setting. - Convert to a timestamp with an offset once, as close to the user as possible, using current tz data.
- Everything after that (queues, databases, API calls) deals only in instants.
- For recurring rules, run step 2 again for every occurrence.
- When you display a scheduled post, convert the UTC instant back into the viewer's zone rather than showing the stored string.
How postpeg handles scheduled_at
POST /v1/posts takes an optional scheduled_at:
- It must be an ISO 8601 date-time with an offset,
Zor+01:00. A time with no offset is rejected with a 400, so a bare local time can never be silently read as UTC. - A time in the past, or leaving it out, publishes now.
- It can be at most one year ahead.
- It is stored and returned in UTC.
- Scheduled posts go out within a minute of their time.
DELETE /v1/posts/{id}cancels any targets that have not started publishing.
A post for 09:00 Berlin time on Monday 26 October 2026, the day after the EU clock change:
curl https://api.postpeg.com/v1/posts \
-H "Authorization: Bearer $POSTPEG_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: berlin-launch-2026-10-26" \
-d '{
"account_ids": ["acc_mfz1x0k27d4q8vbn3c5s0a1p9e", "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f"],
"content": "Our Berlin meetup opens registrations today.",
"scheduled_at": "2026-10-26T09:00:00+01:00"
}'The response (trimmed) shows the same instant in UTC:
{
"status": "scheduled",
"scheduled_at": "2026-10-26T08:00:00.000Z"
}Where postpeg fits
postpeg deals in exact instants: you send a timestamp with an offset, and it publishes to every account in the request within a minute of that time through the official platform APIs. Your app keeps the user's zone and recurring rules and turns them into instants. See Publishing for the full request, the scheduling API overview, and the best time to post tool for picking the local time to begin with.