Six KST boundary bugs on a UTC server
The server runs on UTC and the user's day starts in KST. Six bugs with nothing in common on the surface shared one root, and the problem was two reference points mixed inside a single function.
Six bugs were fixed within one week. The symptoms had nothing in common.
The weekly ranking didn't refresh on Monday morning, review cards didn't appear on their due date, and credits refilled without limit. The admin dashboard and the admin list showed different numbers, an announcement was invisible on the day it was scheduled to start, and a monthly referral cap didn't reset when the month did. As it turned out, they had one cause.
The server lives in UTC
This app runs on serverless functions. Their timezone is UTC. The database stores UTC in timestamptz. All of that is correct and there is no reason to change it.
The problem appears when the code asks what "today" is.
new Date().setHours(0, 0, 0, 0);That line means local midnight, and since the server's local is UTC, for a Korean user this value is 09:00 KST. Nothing raises, nothing appears in the log, and on a developer's laptop it looks correct — because the laptop's local is KST.
Two timezones in one comparison
Laid side by side, the six had something more precise in common. The problem was less that they used UTC than that the two sides of one comparison were in different timezones.
Start with the most expensive one: the daily credit refill.
// session callback
today = date key in KST // "2026-09-08"
lastRefill = date key in UTC // "2026-09-07"
if (today !== lastRefill) refill()One side read KST, the other UTC. Between 00:00 and 09:00 KST those two values always differ. For those nine hours, a refill fired every time the balance dropped below the threshold, so credits were effectively infinite.
The last number is what confirms the cause. The people who got extra credits were the people who use the app at night, while the bug's window was open. The amount is small, but it's a billing defect, so it grows as the user base does.
The other five
The other five share the root and resemble each other not at all.
| Feature | What the user saw | Where it slipped |
|---|---|---|
| Weekly ranking | Monday 00:00–09:00 study counted toward last week, and last week's standings stayed on screen | getDay() set the week start, which is really 09:00 KST Monday |
| Review cards | Cards appeared only at 09:00 on the due date; morning learners saw nothing | nextReviewAt stored at UTC midnight, compared as an absolute instant |
| Referral monthly cap | The month changed but the cap held until 09:00 on the 1st | Month boundary computed in UTC |
| Admin date filter | List totals disagreed with dashboard totals | The filter saw a UTC day, the dashboard a KST day |
| Announcements | Visible only from 09:00 on the start date | The admin's input was parsed as UTC by the server |
The admin filter and the announcements had already been half fixed to KST. The dashboard looked at KST while the list filter looked at UTC, so two numbers on one screen disagreed.
How it was fixed
The timezone helpers were collected into one file and the boundary functions were separated by name: week start, month start, midnight N days out, date key, admin input parsing.
Storage stayed UTC and only the boundary computation changed. There was no schema change.
There was a one-time effect. Moving the week start nine hours earlier pulls Sunday-night activity into the current week. That favours users, so it stayed. Stored review dates were not backfilled; the next review overwrites them, and until then a card appears at most nine hours late.
I made the same mistake again
This wasn't the first time I'd made this mistake. In another project, in an entirely different codebase, the reference date for a user's utterance was being computed in UTC. The symptom then was also "between 00:00 and 09:00 Korean time". The conclusion then was to stop relying on the pod's timezone setting and pin the offset in code.
That conclusion was right, but it didn't travel to the next project. When a new repository started, nothing stopped me.
Could a lint rule or tests catch this?
I still don't know whether a linter can. A rule banning setHours(0,0,0,0) and getDay() in server code is writable, but on the client those are the correct calls, so it would have to branch on file path, and I suspect that once a rule grows that complicated the exception comments multiply until it stops meaning anything.
I fixed six of these without writing a single unit test. A handful of cases feeding 08:59 and 09:01 KST would have caught all six, and would catch the next break in the same places.
A ranking that won't refresh and credits that refill forever look completely unrelated, so they arrived as separate bugs and were fixed separately. If I'd generalised the sentence "the server's local midnight is 09:00 KST" while fixing the first one, the other five would have been done in a day.
End