RealFeedApp sends a daily digest — a batch of science and tech articles, generated once a day, timestamped so users can see when it was built. In testing, the timestamps were always right. In production, a slice of users kept seeing yesterday's digest labeled as today's, or today's labeled as tomorrow's, and it correlated with nothing I was looking at first: not load, not the queue, not the digest generation job itself.
The correlation I eventually found was location. Users far enough east of me saw it. Users near my own timezone didn't.
Where the bug actually was
The generation job ran on a fixed schedule, server-side, and stamped each digest with datetime.now() at creation time — no timezone attached, just a naive local datetime in the server's own zone. The client received that timestamp and displayed it directly, formatted for readability but not reinterpreted.
That's fine as long as "server's local time" and "user's local time" are close enough that nobody crosses a date boundary between them. They weren't. A digest generated at 11:47 PM server time was already the next calendar day for a user several hours further east — and depending on exactly when within that window someone opened the app, the displayed date flipped a day forward or stayed a day behind what they expected, seemingly at random from their side.
I spent the first stretch of debugging looking at the generation job itself — timing the cron trigger, checking for double-runs, checking whether two digests were somehow getting created in the same window. All of that was fine. The job ran once, on schedule, every time. The bug wasn't in when the timestamp was created. It was in what the timestamp meant once it left the machine that created it.
The fix
I stopped treating "server time" as a reasonable default and moved the boundary earlier: every timestamp is generated, stored, and passed between services as UTC, full stop. No naive datetimes cross a boundary — not into the database, not into an API response, not into a log line I might later use to debug something else.
# before — implicitly server-local, meaning depends on where "server" is
created_at = datetime.now()
# after — explicit, unambiguous regardless of who reads it or from where
created_at = datetime.now(timezone.utc)
Formatting into a user's local time became the client's job, done once, at the very last step before something hits the screen — not baked into the value at creation time, where "local" silently meant "local to a machine the user has never heard of."
What actually changed in how I write timestamp code now
The rule I follow now: a naive datetime is not a smaller version of a timezone-aware one — it's a different, less specific piece of information, and the two shouldn't be allowed to touch without an explicit conversion in between. Before this bug, I treated datetime.now() as a shorthand I'd get around to fixing "for real" later, once localization mattered. It already mattered. I just hadn't run into the user who proved it.
The debugging cost here wasn't in fixing the timestamp — that part took an hour. It was in the two days before that, spent checking whether the job that created the value was broken, when the value it created was never wrong on its own terms. It only became wrong the moment something without my server's specific context tried to read it.
That's the pattern I keep running into, whatever the bug: the code that produces a value is rarely lying. It's usually the code somewhere downstream, assuming it knows something about that value's context that was never actually promised.
The follow-up bug UTC alone doesn't fix
A reader pointed out something I'd missed, and it's worth adding here rather than pretending the UTC fix was the whole story: UTC solves the problem for an instant — a single point in time, unambiguous everywhere. It doesn't solve the problem for a calendar date, and those are genuinely different questions once you're storing data rather than just comparing two instants.
Something like a daily streak or "which day did this happen on" doesn't actually want an instant — it wants a local date, and a local date only exists relative to a specific timezone. If you take a UTC instant and later collapse it down to a bare date without keeping the offset it happened at, you've thrown away the one piece of information you'd need to recover "which day, for this specific user" — and you can't get it back afterward, no matter how carefully you generated the UTC value in the first place.
The fix for that layer is one step further than "store everything in UTC": store the UTC instant and the offset the client reported, and derive the local date from both, on demand, rather than baking a date in at write time. Storing a bare date turns out to be its own mistake — a different one from the naive-datetime mistake this post started with, but caused by the same underlying habit: treating a piece of information as more specific than it actually is.
I write about things that break while I build Boolflow and RealFeedApp.
Top comments (0)