ABS pubDate: Timezone UTC-Only, the Decision
ABS coerces pubDate to a UTC Date via z.coerce.date() and sorts by epoch ms. Local-clock pubDates are an anti-pattern; DST swaps a post's rank twice a year. The schema, the comparator, the verify.

The decision in one sentence
ABS coerces every frontmatter pubDate to a UTC Date via z.coerce.date() and sorts by that Date’s underlying epoch milliseconds — there is no second timezone, no local-clock fallback, no per-author offset field. The schema line is the contract; the sort comparator reads it; the DST calendar is what happens when the contract is ignored.
Two files carry the rule:
src/content.config.ts—pubDate: z.coerce.date(). Zod parses a string into aDate; thatDateis the canonical value downstream.src/pages/index.astroandsrc/pages/rss.xml.js—(a, b) => b.data.pubDate - a.data.pubDate. SubtractingDateobjects coerces them viavalueOf()(epoch ms). The order is wall-clock-independent: a date in Tokyo at 09:00 JST and the same instant at 00:00 UTC compare as identical.
That’s the entire decision. The rest is why anything else is wrong.
What z.coerce.date() actually does
z.coerce.date() runs new Date(input) on whatever you pass it. The output is a JavaScript Date, stored as UTC epoch milliseconds (integer ms since 1970-01-01T00:00:00Z). The wall-clock portion — year, month, day, hour — is derived from that integer using the system local timezone for display.
Two consequences:
- The sort is in UTC.
b.data.pubDate - a.data.pubDatesubtracts epoch ms. No timezone offset enters the comparison. ADatefor2026-03-08T13:00:00Zsorts before2026-03-08T15:00:00Zthe same way everywhere — VPS, Mac, the feed reader’s render server. - Display is whatever the consumer’s locale says. Browsers format via
toLocaleString(); the RSS feed via RFC-822. ABS never writes apubDatestring into HTML by hand; it always hands theDateto a formatter.
That separation — UTC internally, locale externally — is the whole game.
The DST trap, walked through
US daylight saving time changes twice a year:
- 2026-03-08 — spring forward at 02:00 local → 03:00 local. Local UTC offset goes
-05:00(EST) →-04:00(EDT). - 2026-11-01 — fall back at 02:00 local → 01:00 local. Local UTC offset goes
-04:00(EDT) →-05:00(EST).
Suppose an editor publishes a post on the evening of 2026-03-07 with the local-time string "2026-03-07 23:30". The string alone carries no timezone. z.coerce.date() parses it with new Date("2026-03-07 23:30") — and JavaScript’s Date.parse rules treat an offset-less datetime as local time of the host running the parser.
So:
- If the VPS parses it, the post’s epoch ms is what the VPS’s clock says “2026-03-07 23:30” means.
- If the editor’s Mac parses it (e.g. via a local preview), it’s the Mac’s clock.
Worse: the VPS is on UTC year-round, so its “2026-03-07 23:30” parses to 2026-03-07T23:30:00Z. The editor’s Mac in America/New_York parses the same string to 2026-03-08T04:30:00Z (EST is -05:00). Same markdown file, two UTC timestamps five hours apart, depending on who reads it first.
Why ordering flips on a DST day
The failure shape isn’t subtle. Take three posts published around a DST boundary:
| post | frontmatter string | editor-laptop intent (PDT) | UTC epoch | rank |
|---|---|---|---|---|
| A | 2026-03-07 22:00 | local evening | 2026-03-08T05:00:00Z | 1st |
| B | 2026-03-08 06:30 | local morning | 2026-03-08T13:30:00Z | 2nd |
| C | 2026-03-08 23:00 | local night | 2026-03-09T06:00:00Z | 3rd |
That order looks right — A is the latest in real-world time, so it sorts first. Add a missing timezone and the strings become lexicographic nightmares: 22:00 < 06:30 < 23:00 only because the date precedes the time in the literal.
The fall-back weekend makes this provably irrecoverable. On 2026-11-01 the local hour “01:30” occurs twice. The same string "2026-11-01 01:30" parses to two different Date objects — the first occurrence (EDT, -04:00, → 2026-11-01T05:30:00Z) or the second (EST, -05:00, → 2026-11-01T06:30:00Z). The exact same string yields two valid Dates one hour apart, both UTC, both real. No string-formatting rule disambiguates. The only escape is to refuse ambiguous strings in the first place.
Why we don’t add a timezone field
Some CMSes store a wall-clock string plus timezone: "America/Los_Angeles" and resolve at render. ABS doesn’t, for three reasons:
- The sort is already UTC. An offset adds no information to the comparator —
2026-07-14T11:30:00-07:00and2026-07-14T18:30:00Zproduce the same epoch ms. The offset field is a rendering hint, not a sort key. ABS doesn’t render local times anywhere. - Two-coupled-fields-wrong > one-unambiguous-field-right. A
timezonefield invites drift: an editor changes the time, forgets the offset, ships aDatewith the wrong wall-clock label. - Astro doesn’t need it.
z.coerce.date()already handles ISO-8601 strings with offsets. A siblingtimezonefield would re-implement what the standard library already does correctly.
The same logic kills “store the wall-clock string, format at render.” Format-at-render is locale-dependent and DST-dependent; store-at-write in UTC is invariant.
Edge cases the schema already handles
Three shapes show up in frontmatter; the schema decides each:
pubDate: 2026-07-14(date-only).z.coerce.date()parses to2026-07-14T00:00:00Z. Build accepts; editorial preference is the fullT00:00:00Zso the file is grep-uniform with the corpus.pubDate: 2026-07-14T11:30:00-07:00(offset). Parses to2026-07-14T18:30:00Z. Correct. Use when the editor wants to capture local-clock intent (“11:30 my time”) with an unambiguous UTC moment.pubDate: 1718607600000(epoch ms). Parses to that instant. Rejected by convention — unreadable in code review. Schema accepts; author shouldn’t write it.
The schema’s tolerance is wide on purpose: a build that fails on 2026-07-14 (vs. 2026-07-14T00:00:00Z) blocks valid posts. Better to accept the date and flag it in review.
The verify
Three checks confirm the contract holds on the live site:
# 1. RSS feed pubDates must read descending.
curl -sS https://agenticbotsitter.com/rss.xml \
| grep -oE '<pubDate>[^<]+' | head -20
# Expected: descending RFC-822 timestamps.
# 2. Repo frontmatter must all parse to valid Dates.
find /srv/abs-site/src/content/posts -name '*.md' -print0 \
| xargs -0 grep -hoE '^pubDate: .*$' | head -20
# Expected: strings with 'T'+'Z' or an offset. Date-only is allowed but is the smell.
# 3. DST-trap test: change -04:00 → -05:00 on the same wall-clock time.
# Different epoch ms confirms the offset survives parsing.
node -e 'console.log(new Date("2026-11-01T01:30:00-04:00").valueOf() -
new Date("2026-11-01T01:30:00-05:00").valueOf())'
# Expected: 3600000 (one hour).
The “publish a test post at local 11:30pm” experiment in the brief: set pubDate: 2026-07-14T23:30:00-07:00 (West-Coast late night) and confirm it ranks after any post with pubDate: 2026-07-15T06:00:00Z (UTC morning). The first is “really” 06:30Z; the second is 06:00Z. The second should sort first by 30 minutes. If it sorts the other way, something in the chain is string-comparing, not Date-subtracting.
What we don’t do
- Store
pubDateas a string. Strings are locale-dependent and DST-blind. Always coerce. - Add a
timezonefield. One UTC field is unambiguous; two coupled fields invite drift. - Compare
pubDatestrings lexicographically. UseDatearithmetic; let JS coerce to epoch ms. - Accept “yesterday”, “now”, or relative strings. ISO-8601 only.
- Render
pubDatein any user’s local timezone on the site. The feed and post pages emit absolute timestamps; readers translate to their locale. We don’t ship a “your time” widget. - Audit authors for offset errors at write time. The schema accepts the strings; the editorial convention is the gate. A build that fails on offset errors would block valid posts.
Sources
- Astro Content Collections — Zod schema (z.coerce.date)
- MDN — Date.UTC
- ABS companion: ABS Sort Pages: By Grade and Date, the Rules
- ABS companion: ABS RSS and Reader-Friendly Output: The Decisions
Last verified: 2026-07-14 against src/content.config.ts (pubDate: z.coerce.date()) and the live /rss.xml (sorted by b.data.pubDate - a.data.pubDate, no string-comparison visible in the rendered feed).


Submit a take
Have a different read on this? Drop a comment below — your email isn't published, and I read every one. Nothing leaves the site until I approve it.