Medium wave · 540–1600 kHz60 stn
1563kHz

Your Publish Date Is a Timezone Bug

A towering brass astronomical clock whose three enormous dials each point to a different hour, their gear trains grinding out of phase against one another while a single dated page slips backward through the mechanism, neon teal and amber light bleeding along copper timing rods, dark steampunk chronometry vault wreathed in fog

You write date: 2026-03-13 in the frontmatter. The card renders “March 12, 2026.” You reload. Still the twelfth.

Nothing is broken. Your build did exactly what you told it to. You told it to parse a date-only string, which JavaScript resolves to midnight UTC, and then you formatted that instant in a zone seven hours west of UTC. Midnight UTC on the thirteenth is five in the afternoon on the twelfth in Los Angeles.

One character of frontmatter, one day of drift, and no error anywhere in the pipeline.


The two parsing rules nobody remembers

The whole class of bug traces back to a single asymmetry in the ECMAScript spec:

// Date-only form -> parsed as UTC
new Date('2026-03-13').toISOString()
// '2026-03-13T00:00:00.000Z'

// Date-time form with no offset -> parsed as LOCAL time
new Date('2026-03-13T00:00:00').toISOString()
// '2026-03-13T07:00:00.000Z'   (on a machine in America/Los_Angeles)

// Date-time form with an explicit offset -> unambiguous
new Date('2026-03-13T00:00:00-07:00').toISOString()
// '2026-03-13T07:00:00.000Z'   (on every machine, everywhere)

Adding T00:00:00 — which looks like a no-op, like being more explicit — changes the parse by seven hours. That is not a quirk of one runtime. It is the specified behaviour, and every JavaScript engine implements it.

Anything outside those ISO shapes is worse. new Date('March 13, 2026') and new Date('Fri Mar 13 2026 10:30:00 GMT-0700') are implementation-defined. Engines happen to accept them, and mostly agree, but nothing in the spec obliges them to. If your frontmatter dates are in a human format, you are relying on undefined behaviour that has simply not bitten you yet.


Static sites have three clocks, not one

A server-rendered page formats a date at request time, in a process you control, and if it is wrong it is wrong consistently. A static site freezes the answer into HTML at build time — and there are three different machines with an opinion about what “today” means.

  1. The authoring machine. Where the frontmatter was typed, in whatever zone you happen to live in.
  2. The build machine. Almost every CI container and every deploy runner is UTC. Your laptop is not. The date baked into dist/ was computed by a machine in a different zone than the one you tested on.
  3. The reader’s browser. Only relevant if you format client-side — but if you do, it now disagrees with the HTML you shipped.

That third one is the nastiest failure mode, because it produces a page that changes after paint. The server-rendered markup says March 12. An effect calls toLocaleDateString() and rewrites it to March 13. In React that is a hydration mismatch; in an island it is a silent flicker. Either way the fix is not to suppress the warning. The fix is to stop computing a date in two places with two different clocks.


Sorting is where it stops being cosmetic

An off-by-one on a card is embarrassing. An off-by-one in sort() reorders your entire archive.

Sort a post list by a field that is a string in some files and a Date in others and you get garbage comparisons. Sort by a coerced Date where half the entries are UTC midnight and half are local midnight and you get posts published on the same calendar day interleaving in an order that depends on which zone the builder was in.

Then there is the tiebreaker problem, which is specific to static builds:

// Two posts, same date-only frontmatter -> identical timestamps
posts.sort((a, b) => b.data.publishedAt - a.data.publishedAt)

Array.prototype.sort has been guaranteed stable since ES2019, so this does not shuffle randomly — it preserves input order. Input order is whatever your content loader handed you, which is ultimately directory read order, which is filesystem-dependent. Your machine and the build container can disagree, and now the same commit produces two different homepages. That is the same failure mode as building it twice, and it is just as hard to notice.

Store a real time of day and the tie never happens.


Three rules that make it go away

1. Store an instant, not a calendar day

Every date in content should carry an explicit offset. No bare 2026-03-13, no naked T00:00:00.

publishedAt: '2026-03-13T10:30:00-07:00'

That string means exactly one moment in time and parses identically on every machine on earth. What calendar day it displays as is a rendering decision, made later, on purpose.

2. Reject the ambiguous forms at the schema boundary

Do not trust yourself to remember at 11pm. Make the build refuse:

const isoWithOffset = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/

const posts = defineCollection({
  schema: z.object({
    title: z.string(),
    publishedAt: z
      .string()
      .regex(isoWithOffset, 'publishedAt needs a full ISO timestamp with an offset')
      .transform((s) => new Date(s)),
  }),
})

Note what this deliberately does not use: z.coerce.date(). Coercion is the problem, not the solution — it happily accepts 2026-03-13 and silently hands you UTC midnight. A schema that accepts ambiguous input is not validating anything. This belongs in the same category as everything else that should fail the build.

3. Format in one pinned zone, at build time

Never let the ambient zone decide. Pass timeZone explicitly, every time:

const displayDate = new Intl.DateTimeFormat('en-US', {
  dateStyle: 'long',
  timeZone: 'America/Los_Angeles',
}).format(post.data.publishedAt)

Now the output is identical whether it was built on your laptop, in a UTC container, or on a runner in Frankfurt. The site has an editorial timezone — the one the publication lives in — and it is written down in code instead of inherited from an environment variable.


The same instant needs three different serialisations

Once you hold a real instant, the rest of the page is mechanical. What trips people up is that each consumer wants a different format, and reaching for .toString() for all three is how malformed feeds happen.

const d = post.data.publishedAt

d.toISOString()
// '2026-03-13T17:30:00.000Z'  -> <time datetime="...">, sitemap <lastmod>

d.toUTCString()
// 'Fri, 13 Mar 2026 17:30:00 GMT'  -> RSS <pubDate> (RFC 822)

displayDate
// 'March 13, 2026'  -> the visible text

The time element is the one people skip. Give it the machine-readable instant and let the human string be the child:

<time datetime="2026-03-13T17:30:00.000Z">March 13, 2026</time>

Crawlers, feed readers and Schema.org consumers read the attribute. Humans read the text. They can differ in presentation precisely because they no longer differ in meaning.

And while you are in there: delete any build-time “3 days ago.” Relative time computed during a build is a lie with a half-life. It was accurate for the ninety seconds between npm run build and the deploy finishing, and it has been decaying ever since. Relative time is a client-side feature or it is not a feature.


Test it by moving the planet

The reason this bug survives review is that everyone reviewing it is in the same zone. You cannot see an off-by-one from inside the offset that produced it.

So change the offset. Build the site under two hostile zones and diff the output:

TZ=Pacific/Kiritimati npm run build && mv dist dist-utc14
TZ=Pacific/Pago_Pago  npm run build && mv dist dist-utc11
diff -r dist-utc14 dist-utc11

Kiritimati is UTC+14 and Pago Pago is UTC-11 — twenty-five hours apart, the widest spread the world offers. If any rendered date, sort order, feed entry or sitemap timestamp depends on the ambient zone, those two trees differ and diff prints it. If the pipeline is correct, they are byte-identical.

That is a gate, not an exercise. Wire it into CI next to your other build checks:

- name: Timezone-independence check
  run: |
    TZ=Pacific/Kiritimati npm run build && mv dist dist-a
    TZ=Pacific/Pago_Pago  npm run build && mv dist dist-b
    diff -r dist-a dist-b

Node honours TZ on Linux and macOS, which covers every CI runner you are likely to use. On Windows, run it in a container rather than fighting the shell.


What about DST?

Mostly, nothing — and that is the point of storing instants. 2026-03-13T10:30:00-07:00 is a fixed moment; daylight saving cannot retroactively move it, because there is no wall-clock left to reinterpret.

The danger is only in reconstructing wall-clock times. Taking a stored instant, pulling the date parts out, and rebuilding a new Date from them will land you in the gap on a spring-forward morning, where 2:30am does not exist and your “same time yesterday” arithmetic quietly returns 3:30am. Adding 24 hours in milliseconds and calling it “tomorrow” is wrong twice a year for the same reason.

If you find yourself doing calendar arithmetic in content code, that is the signal to reach for a real date library or Temporal. For a blog, you almost never need to — you need to store one moment and render it once.


The takeaway

A publish date looks like metadata. It is actually a small distributed system with three clocks in it: the author’s, the builder’s, and the reader’s. Static rendering does not remove the disagreement. It just freezes whichever answer the build machine happened to have.

Store an unambiguous instant. Reject anything less at the schema. Pin the display zone in code. Then build the site twice, twenty-five hours apart, and let diff tell you whether you actually fixed it.