Backend · Reliability

The Same Instant,
Two Different Timestamps

The exact same moment in time, serialized by the exact same database driver, produces a different string depending on which timezone the process happens to be running in. Four of five language implementations had been writing that string straight into a column with no timezone attached — UTC on a CI runner, something else entirely on a developer's laptop — and the fix turned out to belong in a different place in each language, for a reason worth understanding rather than memorizing.

One line of Node, run twice, makes the whole bug visible without reading a single line of application code:

> prepareValue(new Date('2026-08-05T00:00:00Z'))
'2026-08-05T09:00:00.000+09:00'   // process running in Asia/Seoul
'2026-08-05T00:00:00.000+00:00'   // same process, TZ=UTC

Same instant. Same driver. Two different strings, because the driver serializes a timestamp using the process's own local offset before handing it to Postgres — and a TIMESTAMP column with no time zone attached keeps whatever wall-clock digits it's handed and throws the offset away. Nothing about that column can tell the difference between an honest UTC write and a write from a process that happened to think it was nine hours later. Four of five languages in the repository had exactly this defect, each wearing a different costume.

Three Costumes for the Same Defect

Go's time.Now() returns a value carrying the host's local location, and the driver formats it accordingly — UTC on a CI runner, something else on a laptop, silently. Kotlin and Java's LocalDateTime.now() resolves against the JVM's default zone the same way. Java had the sharpest version of it: YearMonth.now(), used to name a monthly statement period and the SQS deduplication ID built from it — not just a timestamp quietly wrong, but the literal name of a period key decided by the wrong clock. Kotlin added its own variant on top: a @Scheduled(cron = ...) job with no zone attribute at all, so even after the period key itself was computed correctly in UTC, the trigger firing it could still fire on the wrong calendar day relative to that key — the fix and the thing that needed fixing living in two different places in the same file.

The Fix Belongs Somewhere Different in Each Language

Go, Java, and Kotlin all share the same underlying mechanism: reading "now" is what reads the host's zone, at the exact moment the call happens. A shared helper — wrapping the same call, forcing UTC — fixes every call site that routes through it, because the wrong value was never constructed in the first place.

NestJS doesn't have that call site to fix, because a JavaScript Date is already an absolute instant the moment it's constructed — there's no local-zone reading anywhere in new Date() to correct. The divergence only appears later, at the boundary where the driver serializes that already-correct instant using the process's zone. The only place a fix can possibly live is the process itself: process.env.TZ = 'UTC', set as the literal first import of main.ts, ahead of even the tracing setup — Node only applies a runtime TZ change to date operations that happen afterward, so anything imported above the pin would still see the old zone. It lives in its own file under src/config/ rather than as a bare statement in main.ts, because an existing harness rule already restricts touching process.env to files in that one directory — the pin got moved to satisfy the rule that already existed, rather than the rule getting an exception carved into it.

A Test Runner That Quietly Refuses to Cooperate

Pinning the timezone for the running app was the easy half; making the test suite see the same pin was not. Jest's setupFiles looked like the obvious mechanism and simply doesn't work for this — it runs inside the sandboxed test environment, where the assignment to process.env.TZ never reaches the actual operating-system tzset call. Checked directly rather than assumed: the environment variable really did read back as 'UTC' after the assignment, and getTimezoneOffset() still reported the host's real offset regardless. globalSetup runs before that sandbox exists at all, on the real process, which is the one place the pin actually takes hold before any worker starts.

The Only Verification That Actually Means Anything

A test suite running inside a UTC container passes trivially whether or not the underlying code is fixed — a UTC host can't distinguish a real fix from a bug that simply never had the chance to misbehave. The only verification that proves anything is running the exact same suite a second time with the process timezone deliberately set to Asia/Seoul. Kotlin and Java both failed by exactly nine hours before their fixes landed, and passed cleanly under both zones afterward — a clean, mechanical, unambiguous proof that the defect was real and that the fix actually addressed it, not a coincidence of wherever the test happened to run.

Java's version of that verification needed one more layer, because a single self-consistent JVM has nothing external to disagree with itself — every timestamp inside one process agrees with every other timestamp in that same process, wrong or not, so a bare zone change alone doesn't automatically fail anything. Its regression test anchors specifically against Instant.now() — the one reading in the whole system that is already an absolute point on the timeline, impossible to get wrong by zone — and checks the persisted timestamp against it. That's what turns a defect invisible from inside a single process into one visible from outside it.

Five languages now compute one thing the same way for the first time in the repository's history — not because they share a clock library, or because "just use UTC" was ever in doubt as the right rule. Because each one finally has the specific shape of fix its own runtime actually needed: a corrected reading at the call site in three of them, and a corrected process in the fourth, for a reason that was worth learning once rather than papering over with the same fix copied five times.

Further reading in the repo

docs/conventions.md — the repo-wide timezone rule, and the table of where the fix belongs per language · timezone.config.ts — the process pin, and why it has to run first