Stale timezone data in a pinned runtime

A recurring meeting scheduled for 09:00 in Santiago starts arriving in calendars at 08:00. Nothing in the application changed. The country moved its daylight saving transition, the change was published as a new time zone database release months ago, and the container has been running the same pinned image since before it.

Locale data is a dependency that changes without appearing in any lockfile. Pinning a runtime pins that data too, which is exactly what pinning is for and exactly what makes this failure possible.

A pinned runtime drifting away from reality An image is built with one release of the time zone database. Months later a jurisdiction changes its rules and a new release is published. When the change takes effect, readers in that region shift by an hour while the pinned container continues applying the old rules. How zone data goes stale 2024-03 Image built tzdata 2024a baked in 2024-09 A country changes its rules published as tzdata 2024b 2024-10 The change takes effect readers there shift by an hour 2024-10 Your container does not pinned image, old rules
Nothing broke. The world changed and the container did not.

Root cause: the rules are data, and the data has a date

The IANA time zone database changes several times a year, because governments change their rules and occasionally correct historical records. ICU bundles a snapshot of it, and a runtime bundles a snapshot of ICU, so a container image carries the rules as they stood when the image was built.

That is normally invisible. Most zones do not change in most years, and a change announced in one hemisphere is irrelevant to readers in another. It becomes visible at the moment a change takes effect for a region you serve — and the effect is a plain one-hour error affecting only readers in that region, which is the hardest kind of report to interpret.

Two properties make it worse than it sounds. The error is regional, so it looks like a bug in whatever feature the affected customer happened to be using. And it applies to future times as strongly as present ones: an event stored as a computed instant was computed with the old rules, so it is wrong even after the runtime is updated.

What an out-of-date data bundle affects Current times in a region are wrong after a rule change takes effect. Future scheduled times are the worst case, because they are computed now against rules that will have changed by then. Historical timestamps move only when a past rule is corrected. Display names and currency patterns drift slowly and matter less. What stale data gets wrong Affected? Visible as Current time in a zone after a rule change an hour out for one region Future scheduled times yes — the worst case meetings at the wrong time Historical timestamps only if a past rule was corrected an old record shifts Zone display names rarely an outdated city or country name Currency and locale patterns occasionally a withdrawn currency still formatting
The second row is the one that produces support tickets: a meeting booked correctly and attended an hour late.

Minimal reproducible example

# Two containers, same code, different image ages
docker run --rm node:20-bookworm@sha256:<old> node -p \
  "new Intl.DateTimeFormat('es-CL',{timeZone:'America/Santiago',timeStyle:'short'}) \
     .format(new Date('2025-09-10T12:00:00Z'))"

docker run --rm node:20-bookworm@sha256:<new> node -p \
  "new Intl.DateTimeFormat('es-CL',{timeZone:'America/Santiago',timeStyle:'short'}) \
     .format(new Date('2025-09-10T12:00:00Z'))"
#   The two disagree by an hour across a changed transition date.

The fix has two halves: current data, and data-independent storage

Updating the runtime fixes today’s rendering and does not fix anything already stored as an instant. Both halves are needed.

Keep the data current. Rebuild images on a schedule rather than only when application code changes, and record what data the build contained so a later diagnosis starts with a fact.

FROM node:20-bookworm-slim@sha256:<digest>
RUN node -e "console.log('icu', process.versions.icu, 'tz', process.versions.tz ?? 'n/a')" \
  > /app/BUILD_DATA_VERSIONS

Store future events in a form the rules cannot invalidate. A future meeting is a wall-clock time in a named zone — “09:00 on 10 September, America/Santiago” — and not an instant. The instant is derived when it is needed, against whatever rules are current then.

// Store this…
type ScheduledEvent = { localDateTime: string; timeZone: string };
//   { localDateTime: '2025-09-10T09:00', timeZone: 'America/Santiago' }

// …and derive the instant at read time, never at write time.
export function instantFor(e: ScheduledEvent): Date {
  const dtf = new Intl.DateTimeFormat('en-CA', {
    timeZone: e.timeZone, hour12: false,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit',
  });
  // Resolve the wall time against current rules by searching the offset.
  const guess = new Date(e.localDateTime + 'Z');
  const parts = dtf.formatToParts(guess);
  const asRendered = `${part(parts,'year')}-${part(parts,'month')}-${part(parts,'day')}` +
    `T${part(parts,'hour')}:${part(parts,'minute')}`;
  const drift = Date.parse(e.localDateTime) - Date.parse(asRendered);
  return new Date(guess.getTime() + drift);
}

A past event is the opposite: it happened at a definite instant, and that instant should be stored, because re-deriving it from a wall time would move it if a historical rule were corrected.

Five habits that keep time zone rules current The data version is recorded in build metadata so a diagnosis can start with it. Images are rebuilt on a schedule rather than only when code changes. Future events are stored as a zone plus a wall-clock time rather than as a computed instant. Instants are recomputed at read time so a rule change is applied. And an alert fires when a running image data is older than an agreed threshold. Keeping data current without surprise upgrades 1 Record the data version at build time ICU and tzdata, in build metadata 2 Rebuild on a schedule, not on demand monthly is enough for zone rules 3 Store future events as zone + wall time not as a precomputed instant 4 Recompute instants at read time so a rule change is picked up 5 Alert when the data ages past a threshold a stale runtime is an operational fact
The third and fourth are the durable fix: stored instants freeze a rule, stored wall times do not.

Why “just use UTC” is not the whole answer

Storing everything as a UTC instant is excellent advice for events that have happened and insufficient for events that will.

The reason is that a future wall-clock commitment is not a commitment to an instant. Someone who books a 09:00 meeting in Santiago means nine in the morning where they are, and if their government moves the clocks between now and then, they still mean nine in the morning. Converting to UTC at booking time freezes the current offset and produces a meeting at 08:00 or 10:00 local — which is precisely the report at the top of this page.

The same reasoning applies to recurring events, and more strongly: a daily 09:00 alarm crosses a transition twice a year by definition. Storing the recurrence rule with its zone, and expanding it into instants at read time, is the only representation that survives.

This is a storage-model decision rather than a formatting one, which is why it belongs beside the runtime question. Current data without the right model fixes rendering and leaves stored commitments wrong; the right model without current data derives instants from rules that are out of date.

Making the drift visible before a customer does

The failure has no error to alert on, so the only way to see it coming is to measure the age of the data the runtime is actually using and treat that as an operational metric like any other.

Emit the data version as a startup metric. The ICU version, and the time zone database version where the runtime exposes it, published once per process start. A dashboard of “oldest data version currently serving traffic” turns an invisible property into a number someone can look at.

Alert on age rather than on version. A rule such as “no instance running data more than four months old” is meaningful and stable; an alert naming a specific version needs editing every release. Four months is a reasonable starting threshold because it is shorter than the interval at which most jurisdictions announce changes.

Watch the announcements for the regions you serve. Time zone changes are published well in advance and are not numerous. A team serving five countries can follow the relevant announcements with almost no effort, and knowing a change is coming converts an incident into a scheduled rebuild.

Test the transition, not the day. An assertion that formats a timestamp on both sides of an upcoming transition is the most direct check available, and it fails on a runtime whose data predates the change. Adding one for each region whose rules are about to move gives a build-time answer to “have we picked this up yet”.

None of this is expensive, and the reason to bother is the asymmetry of the outcome: the cost of being a month late with a rebuild is normally zero, and occasionally it is every scheduled event in a country arriving at the wrong hour.

Verification

test('a future event survives a rule change', () => {
  const e = { localDateTime: '2025-09-10T09:00', timeZone: 'America/Santiago' };
  const rendered = new Intl.DateTimeFormat('es-CL', {
    timeZone: e.timeZone, timeStyle: 'short',
  }).format(instantFor(e));
  expect(rendered).toBe('09:00');   // true under any rule set
});
# Operational check: how old is the data this container is running?
node -p "process.versions.icu"
node -e "console.log(Intl.supportedValuesOf('timeZone').length)"   # zone count moves over time

The assertion is the durable one: it passes under every rule set precisely because the stored value contains no instant.

When to escalate

If times are wrong for a region whose rules have not changed, the cause is not stale data. Check that the zone identifier is current — zones are occasionally renamed and old identifiers become links, which some libraries handle differently — and that the identifier reaching the formatter is the reader’s rather than the server’s.

If historical timestamps shifted after an upgrade, a past rule was corrected upstream. That is intended behaviour: the new data is more accurate. If a record must never move, store both the instant and the wall time so the discrepancy is visible rather than silent.

If a zone is missing entirely, the runtime may carry a reduced data set. That is the loading question covered in Intl polyfill bundle size per locale, not an age problem.

FAQ

How often should images be rebuilt?

Monthly is comfortable for zone rules, which change a handful of times a year and are usually announced with weeks or months of notice. A product where a scheduling error is expensive should also rebuild on demand when a change affecting a served region is announced.

Does the operating system time zone database matter, or only ICU?

Both, and they can disagree. ICU carries its own copy for Intl, while some libraries read the system database. A container where one is updated and the other is not produces inconsistent results between two code paths — one more reason to record both versions at build time.

Is Temporal the answer?

It is a much better tool for this: Temporal.ZonedDateTime models a wall time in a zone directly, so the storage model described above stops being something you assemble by hand. It does not change the data-currency question, which is a property of the runtime rather than the API.

Should a user’s zone be stored or detected?

Stored, and confirmed. Detection is a reasonable default and it changes when a reader travels, which for scheduling is exactly the wrong time to guess. An explicitly chosen zone attached to the account, with detection offering to update it, is what avoids meetings moving because someone opened a laptop in another country.

Part of Intl Polyfills & ICU Data Loading.