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.
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.
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.
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.
Related
- Intl Polyfills & ICU Data Loading — where locale and zone data comes from.
- Intl.DateTimeFormat timezone & DST bugs — the API-level failures with the same symptom.
- Node slim ICU falling back to English — data absent rather than aged.
- Date & Number Formatting Standards — the formatting layer above all of this.
Part of Intl Polyfills & ICU Data Loading.