Web Development

Temporal in Node 26: The Bug Was Never Date, It Was Calling a Calendar Day an Instant

Temporal is on by default in Node 26 and the obvious move is a codemod. Don't. The unit of this migration is the database column, not the call site — and until you can say which of the five things Date was doing each column meant, a better API just gives you a more precise wrong answer.

KAKabir AnandLead Developer
11 min read
Flat vector illustration of one large ambiguous shape separating into five distinct smaller objects on a pale ground

On the last Sunday in October, a logistics client's operations board started showing Tuesday's deliveries on Monday. Only for the Lisbon team. Only for rows created before the clocks changed. Not for pickups, which come from a different column. Nobody had deployed anything in nine days, and the board had been correct for two years.

The column was a timestamptz holding midnight. A delivery date is a calendar day — there is no such thing as 14:20 on a delivery date — but the ORM had a Date in hand and the column took one, so that is what went in. Rows written by the Lisbon team during Portuguese summer time stored local midnight as 23:00 UTC on the previous day. All summer, a reader at UTC+1 turned that back into the right day. In late October the same reader was at UTC+0, and every one of those rows came back a day early. Pune never saw it, because a +05:30 reader turns 23:00 UTC into 04:30 on the correct morning and always had.

No date library would have prevented that, and this is the part worth sitting with before you upgrade anything. The code was not wrong. The column was answering a different question from the one the business was asking, and every reader was free to guess which question that was. Node 26 ships Temporal enabled by default, it becomes LTS this October, and the first instinct on every team we've talked to has been a codemod — find every new Date(), replace it with the Temporal equivalent, run the tests. That produces a more precise version of the same ambiguity.

Date is five types wearing one costume

The reason a mechanical port doesn't work is that Date is not one concept. It is a single 64-bit number that five different domain concepts have been borrowing for thirty years, and a codemod cannot tell them apart because the information it needs was never written down anywhere.

  • An exact moment — when a row was written, when a webhook fired, when a payment cleared. Real, absolute, the same for everyone. _Temporal.Instant._ This is the only one Date was ever honestly modelling.
  • A wall-clock promise in a named place — the 07:00 the customer was told their subscription renews, the 18:00 cut-off at the Chennai hub. It has an instant, but the instant is _derived_, and it changes when the zone rules change. _Temporal.ZonedDateTime._
  • A calendar day with no time in it — delivery date, invoice period, date of birth, the day a contract starts. _Temporal.PlainDate._ This is the one that breaks quietly, because giving it a time of day never throws.
  • A time of day with no date — opening hours, a slot template, the daily batch window. _Temporal.PlainTime._ Usually stored as a string today because nobody could face putting it in a Date.
  • An amount of time — the trial length, the SLA window, the gap between two events. _Temporal.Duration_, and the important part is that it distinguishes 24 hours from one day. Those are different lengths twice a year.

Temporal's contribution is not nanosecond precision or immutability, useful as both are. It is that it refuses to let you stay vague. You cannot construct a PlainDate and then ask what time it is. You cannot turn a PlainDateTime into an absolute instant without naming a time zone, and if the wall-clock time you named doesn't exist that day, you have to say in advance what should happen. Every place the new API feels annoying is a place the old one was guessing on your behalf.

Ship the runtime upgrade and the date rewrite as two releases

Node 26 is a major, and Temporal is not the only thing in it. It also drops HTTP and stream surfaces that have been deprecated for several majors — the kind of thing that is fine in your code and not fine in a transitive dependency you have never opened. Bundle that with a semantic rewrite of your date handling and you have built a debugging trap for yourself: when the delivery board is wrong on the Thursday after release, you will not know whether you changed the meaning of a column or whether an HTTP agent somewhere stopped setting a header.

We run the runtime first, on the old code, unchanged, for a full billing cycle. Temporal being available and Temporal being used are separate facts, and the gap between them is where you find out that your image pipeline's third-level dependency was relying on something that is now gone. Only after that does any date semantics move.

Sequence

Node 26 on existing code and hold it for one full billing cycle. Then classify columns, one domain at a time, with the old code still doing the deciding. Then shadow-run the new logic against the old. Then cut over one domain. Deleting the legacy path is the fifth step, not the second, and on the renewals domain we did not reach it for nine weeks.

Start at the column, not the call site

The unit of work in this migration is a persisted contract — a database column, an API field, a queue payload key. Not a function. Pick a column, decide which of the five concepts it holds, write that decision down where a person will trip over it, and only then change the code that touches it. We did this as a literal spreadsheet: column, current storage type, the concept it actually holds, the Temporal type, and the name of the person who confirmed it. Ninety-one columns across two services. Fourteen of them were wrong, in the sense that the storage type and the concept disagreed.

Postgres already has most of the types you need and we had simply not been using them. A calendar day is a date column and maps cleanly to PlainDate. An exact moment is timestamptz and maps to Instant. A time of day is time. The one that has no native home is the wall-clock promise: timestamptz will not keep the zone you meant, so a ZonedDateTime needs two columns — the wall-clock timestamp and the IANA zone id as text — or an RFC 9557 string, which is what ZonedDateTime.toString() gives you, brackets and all. Storing only the resolved instant loses the intent, and the intent is the thing you need when the government moves a DST boundary.

src/delivery/cutoff.ts
1// Before. The column said timestamptz, the business said "a day",
2// and every reader got a vote on which one it meant.
3const deliveryDate = new Date(row.delivery_date); // 2026-10-23T23:00:00Z
4deliveryDate.toDateString(); // Lisbon in winter: the 23rd. Pune: the 24th.
5 
6// After. The type says it once and nobody downstream gets to guess.
7const deliveryDate = Temporal.PlainDate.from(row.delivery_date); // "2026-10-24"
8 
9// A cut-off is a wall-clock time at a named hub, resolved per day.
10// It is not a fixed number of hours before anything.
11const cutoff = deliveryDate
12 .subtract({ days: 1 })
13 .toZonedDateTime({ timeZone: hub.zone, plainTime: "18:00" });
14 
15const late = Temporal.Instant.compare(placedAt, cutoff.toInstant()) > 0;

Two details in that snippet earned their place the hard way. The subtract happens on the PlainDate, before the zone is applied, because "the evening before the delivery day" is a calendar statement and doing it in absolute time gives you the wrong hour on the two days a year it matters. And the comparison goes through Instant.compare rather than the equals method — equals on a ZonedDateTime compares the zone and the calendar too, so the same moment expressed in two zones is not equal to itself. That distinction cost us a real bug later, and it is the single sharpest edge in the whole API.

Editorial photograph of a paper desk calendar with one page half torn, lying on a dark desk beside a coiled cable and a mug in raking window light
Ninety-one columns, fourteen of which stored a concept their type could not represent. The spreadsheet took four days and found more bugs than the rewrite did.

Three domains, three different answers

We migrated delivery dates, hub cut-offs and subscription renewals, in that order, over about eleven weeks. They are all "dates" and not one of them wanted the same treatment.

Delivery dates were the easy one and we did them first deliberately, to get the deployment shape right on something forgiving. Change the column to date, parse to PlainDate, and the entire class of off-by-one-day bugs becomes unrepresentable rather than fixed. Two weeks, mostly spent on a backfill that had to decide, for every historical row, which zone had written it. That question has no clean answer, so we wrote the guess down in an audit column instead of pretending.

Cut-offs were harder because they are per-hub and the hubs are in four zones, one of which observes DST and three of which don't. The old code held a UTC hour per hub and a comment apologising for it. The new code holds a PlainTime and an IANA zone id and resolves an instant per delivery day. Nothing about that is clever — the point is that the resolution now happens at the moment of the decision instead of at the moment somebody typed a number into a config file.

Renewals were the nine-week one. A renewal is a promise about wall-clock time — 07:00 in the customer's zone — and wall-clock promises have two failure modes that absolute time doesn't. The hour can fail to exist, when the clocks jump forward. The hour can happen twice, when they fall back. Temporal will not let you ignore either; it makes you pick a policy up front, and the default policy is reasonable but it is not necessarily yours.

src/billing/nextRenewal.ts
1// A renewal is "07:00 where the customer is", not "+2,592,000,000ms".
2export function nextRenewal(current: Temporal.ZonedDateTime) {
3 // Month arithmetic on the wall clock first: 31 Jan + 1 month clamps
4 // to 28 Feb. Doing this in absolute time gives you 3 March.
5 const wall = current.toPlainDateTime().add({ months: 1 });
6 
7 try {
8 return wall.toZonedDateTime(current.timeZoneId, {
9 disambiguation: "reject",
10 });
11 } catch (err) {
12 if (!(err instanceof RangeError)) throw err;
13 
14 // The hour does not exist, or exists twice. Billing takes the
15 // later instant — and writes down that it had to choose.
16 audit.record("renewal.dst_resolved", {
17 wall: wall.toString(),
18 zone: current.timeZoneId,
19 });
20 return wall.toZonedDateTime(current.timeZoneId, {
21 disambiguation: "later",
22 });
23 }
24}

Calling add directly on the ZonedDateTime would have done nearly the same thing in half the lines, and for most code that is the right call. We dropped down to PlainDateTime on this one path specifically so that reject would throw and we could log it. Twice a year, a few thousand customers get billed at an hour we chose for them rather than the hour we quoted, and finance would rather have a row in an audit table than a support ticket. That is a product decision the type system surfaced, not a technical one — which is the argument for Temporal in a sentence.

The shadow run found things the fixtures never would have

Every guide on this tells you to write golden fixtures, and you should: we keep a list of eleven calendar days that have each broken something in production at some point, crossed with nine zones, and it runs on every commit. But fixtures only test the failure modes you already know about, and the interesting ones on this migration were in our data, not in the calendar.

src/billing/schedule.ts
1// Both paths compute. Only the old one is allowed to charge anyone.
2const legacy = legacyNextRenewal(sub); // Date
3const candidate = nextRenewal(toZoned(sub)); // Temporal
4 
5const drift = candidate
6 .toInstant()
7 .since(legacy.toTemporalInstant());
8 
9if (drift.total("seconds") !== 0) {
10 metrics.increment("renewal.divergence", { zone: sub.zone });
11 log.warn({
12 sub: sub.id,
13 legacy: legacy.toISOString(),
14 candidate: candidate.toString(), // keeps the [zone] annotation
15 });
16}
17 
18return legacy; // for two more weeks

Sixteen days, roughly 180,000 active subscriptions, 3,114 divergences. Two thousand nine hundred and one were DST-shifted renewals where the new code was right and the old code had been quietly moving people's billing hour around for years. Two hundred and six were month-end clamping, where a customer who signed up on the 31st had been drifting forward a day or two per short month. Seven were the new code being wrong, and those seven are why we ran it at all.

The deduplication check used equals, so the same instant written in two zones looked like two different renewals. Seven customers would have been billed twice. No fixture we would have thought to write covers that.

Kabir Anand, Lead Developer

One more thing the shadow run taught us, which we would have got wrong in a quieter way: the since and until methods on a ZonedDateTime do not default to days. The default largest unit is hours, so a three-month difference comes back as 2,184 hours and your "days remaining" badge reads 0. Pass largestUnit explicitly every single time, and if you need calendar units out of a bare Duration, give it a relativeTo — a Duration on its own genuinely does not know how long a month is, and that is correct behaviour rather than an inconvenience.

What we'd do differently

We would not have started with the code. Four days on a spreadsheet of ninety-one columns produced the delivery-board fix, and the delivery-board fix is the one the client actually noticed. The rewrite that followed was competent and slow and, on its own, would have changed nothing about the original bug — a codemod would have replaced the Date with a Temporal.Instant, which is exactly the wrong type, and the board would still have been a day early in Lisbon every October.

We also would not have migrated in one direction. The useful move, which we found late, is to convert at the boundary and leave the middle alone for a while: parse into Temporal types the moment a row or payload is read, convert back to Date at the two or three places that hand data to a library that still wants one, and let the domain logic in between be the only thing that changes. Instant has epochMilliseconds, Date has toTemporalInstant, and the round trip is lossless as long as you were holding an instant to begin with. If it isn't lossless, that is your signal that the value was never an instant.

And we would resist the urge to do this because Node 26 is going LTS. The upgrade is a good reason to run the runtime; it is not a reason to touch date semantics, and the two arriving together is a coincidence of the release calendar rather than a dependency. If your date columns are honest today, the correct amount of Temporal work this quarter is zero. If they aren't — if you have a timestamptz somewhere that everyone on the team describes in conversation as "just a date" — then you have had a bug since before Node 26 existed, and Temporal's only real contribution is that it makes that bug impossible to write down the next time.

Keep Reading

More from the blog

Track Record

The Engineering Partner You Can Build On

Reliable software takes an experienced team that owns delivery end to end. Here’s the track record behind ours.

Book a Free Consultation
01

11+

Years Building Custom Software

02

320+

Projects Delivered Across Web, Mobile & AI

03

85%

Repeat Client Rate

04

12+

Countries Served Worldwide

Trusted by startups and enterprises worldwide

Work With Us

Let’s create
with purpose

Share your goals, timeline, and challenges — we’ll respond with clarity and next steps.

Ambitious ideas deserve thoughtful execution. Start the conversation and let’s define what success looks like.

Team

Acetrum

Est. 2015

4.9/5

Trusted by
top brands

Services interested in:

By submitting, I confirm I’ve read and agree with Privacy and Cookie Policies.

Newsletter

Signals worth
paying attention

No recycled headlines — just the patterns we’re seeing across real client work, distilled into one read a month.

A curated digest of practical thinking and real-world brand perspectives monthly.

No spam. Unsubscribe anytime.