Why Timezone Bugs Never Reproduce on Your Local Machine
Your server sends UTC, your laptop sends local time, and slicing the string hides the gap. Why it only breaks in production, and how to force it locally.
A timestamp on an internal dashboard read 09:41. The event had actually happened at 18:41. Nine hours off, exactly one timezone offset, and the code that produced it had shipped months earlier without anyone noticing. The reason nobody noticed is the interesting part: on every developer machine, the same code printed the right time.
This is a specific failure mode worth naming. The bug is not in the formatting logic alone. It is in the interaction between how the server serializes time and what timezone the developer's machine happens to be in. When those two line up, a broken implementation looks correct.
The code that looks fine and is not
The server returns timestamps as RFC 3339 strings. The frontend needs MM-DD HH:mm. Someone wrote the obvious thing:
// Renders "08-05 09:41" from "2026-08-05T09:41:42.240399Z"
function shortTime(iso) {
return iso ? iso.slice(5, 16).replace('T', ' ') : '-';
}
There is no timezone conversion here. There is no Date object at all. The function takes characters 5 through 16 of a string and prints them. Whatever zone the server encoded, that is what the user sees.
In production the container runs with TZ unset, which means UTC. The payload is "2026-08-05T09:41:42.240399Z", and the slice yields 08-05 09:41. The operator, who reads every clock in their office as local time, sees a number that is nine hours behind reality.
Why your laptop hides it
Run the same server on a developer machine in Seoul and it does not serialize Z. Go's time.Time carries a location, and a driver reading a timestamptz column materializes it in the session zone. On a machine where TZ=Asia/Seoul, the JSON comes out as:
{ "startedAt": "2026-08-05T18:41:06.190270+09:00" }
Now slice characters 5 through 16 of that. You get 08-05 18:41, which is correct. Not because the function converts anything, but because the server already emitted local time and the naive slice preserved it.
That is the whole trap. The function has no timezone logic, so it returns whatever zone the input was written in. Local development supplies the target zone by accident, so the output is right by coincidence. Production supplies UTC, so the output is wrong. The code is identical in both cases.
Force production conditions before you trust a fix
You cannot verify a timezone fix on a machine whose zone matches the display target. The check has no power there, since the broken version passes too.
Start the server with the zone production actually uses:
TZ=UTC PORT=8099 go run . serve
Now the local API returns "2026-08-05T09:41:06.190270Z", byte for byte what production sends. Load the page. If the screen still shows 09:41, you have reproduced the defect on your own machine, with a debugger attached and a two second edit loop.
This single environment variable turns an unreproducible production report into an ordinary local bug. It works for the same reason the bug exists: the server's serialization follows the process zone, and the process zone is yours to set.
Two related habits are worth building. Run at least one automated test with TZ set to something far from your own, since a suite that only runs in your zone certifies nothing about zone handling. And when a bug report says "the time is wrong" and you cannot reproduce it, ask what the server sent rather than what the screen showed. The payload settles it in seconds.
The fix is to parse first, then format in a fixed zone
Once the input is a real Date, the offset in the string stops mattering. new Date() handles Z and +09:00 identically, resolving both to the same instant. From there you format into an explicit zone:
function kstParts(iso) {
if (!iso) return null;
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
const parts = {};
for (const { type, value } of new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Seoul', hourCycle: 'h23',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}).formatToParts(d)) parts[type] = value;
return parts;
}
Three decisions inside that block are worth defending.
Pin the zone instead of using the browser's. A bare toLocaleString() follows whatever the client machine reports. That is right for a consumer product where each user wants their own clock. It is wrong for an internal tool where everyone in the company reads one shared timeline, and where a laptop with a misconfigured zone silently produces different numbers than the person sitting next to them.
Set hourCycle: 'h23'. The combination of the en-US locale and hour12: false renders midnight as hour 24, so 00:05 comes out as 24:05. It is correct once per day and wrong once per day, which is the worst kind of bug to catch by inspection. Naming the cycle removes the ambiguity.
Use formatToParts rather than string output. Locale formatting inserts its own separators and orders fields by locale rules. Pulling named parts and assembling them yourself keeps the output stable no matter what the locale would have done.
Date-only fields drift by a full day
The obvious targets are fields showing hours. The ones that get missed show only a date:
expiresAt.slice(0, 10) // "2026-08-05"
This looks harmless because there is no visible clock to be wrong. It is off by a day for a nine hour window every single day. An expiry at 2026-08-06T07:00:00+09:00 is 2026-08-05T22:00:00Z, so the slice prints August 5 for something that expires on August 6. For a license page or a billing screen, that is a support ticket waiting to happen, and a harder one to diagnose than a visibly wrong clock.
When you sweep a codebase for this, search for the string operation rather than the concept. Slicing an ISO string is the pattern, and it looks the same whether the target is a date or a full timestamp.
One category is safe and does not need touching: relative time. A function computing "3 hours ago" from Date.now() - new Date(iso) compares instants, and instants have no zone. Leave those alone.
Where the conversion belongs
Storage stays in UTC. Databases keep timestamptz, APIs keep RFC 3339, logs keep whatever the platform emits. The conversion happens once, at the moment of display, and nowhere else.
This is not a stylistic preference. Converting early means every downstream consumer inherits a zone decision it did not make and cannot see, and the zone is invisible in the value once it has been baked into a formatted string. Converting at the edge keeps one representation in transit and one presentation rule at the boundary.
The practical form of that rule is a small set of shared formatters. Scattered slice() calls are not a style problem, they are the reason the next screen someone builds will have the same defect. When every call site goes through named helpers, fixing the zone once fixes everything, and the next developer has an obvious thing to reach for.
Common questions
Does this only affect JavaScript frontends? No. Any layer that treats a serialized timestamp as text has the same exposure. Templates that print a raw field, log formatters that concatenate strings, CSV exports built with string joins. JavaScript makes it especially easy because slicing a string is shorter than constructing a formatter.
Why not just set the container timezone to match the users? It works until it does not. You then have a system whose correctness depends on an environment variable in a deploy manifest, and any service that forgets it produces wrong output. It also breaks the moment you have users in a second zone. Keeping UTC everywhere and converting at display keeps the invariant in code where it can be tested.
Is Intl.DateTimeFormat fast enough for a table with hundreds of rows?
Constructing a formatter is the expensive part, not calling it. If you render large tables, build the formatter once at module scope and reuse it, rather than constructing one per cell. For a few dozen rows the difference is not measurable.
How do I know I found every affected site?
Grep for the mechanism, not the meaning. Search for .slice( applied to fields whose names end in At or Date, and for any place a timestamp reaches the DOM without passing through a formatter. Then flip the server to TZ=UTC and look at the screens, since a wrong value is easier to see than a missing call.
Related posts
When Sorting Silently Rebinds Your Files to the Wrong Records
An in-place sort plus index-derived filenames produces output that builds, loads, and validates fine while pointing every record at the wrong file.
Why preventDefault Does Not Stop a Global Keydown Listener
Two features bound the same shortcut and both fired. preventDefault cancels default actions, not other listeners. Here is the fix that survives the switch.
The Safety Gate That Passed Everything, Including a 404
A content filter reported clean for months. It was reading nothing. Four ways a check silently inverts into an approval, and how to make failure loud.
A Feature That Never Ran, Because One Service Lacked a Key
A feature failed every request from the day it shipped. One of four deploy targets was missing a credential, and the local environment hid it.