Time in React: The Bugs That Only Show Up at 2 AM

A date is just a value. It sits there in your data looking as harmless as a string or a number, and for a while it behaves. Then one day a user in another timezone reports that their meeting shows an hour off, or a "created today" badge appears on something made yesterday, or a date field creeps backward by one day every single time someone saves the form. Suddenly the most boring data type in your app is the source of the weirdest bug in your tracker.
Time is deceptive because it looks simple and is not. The complexity is real and mostly invisible on the machine you develop on, because your machine has one timezone, and so the entire category of problems — timezones, daylight saving, what "midnight" even means — stays hidden until someone in a different part of the world hits it. And they always hit it, usually in production, usually at an hour that makes the bug's name feel personal.
So this article is about the small number of ideas that keep time from breaking a React app. Not a date-library tour — a way of thinking about time that makes the whole class of bugs stop happening.
The One Rule That Prevents Most of It
If you take nothing else from this: store and transmit time in UTC. Convert to the user's local timezone only for display. That's it. That single discipline, applied without exception, eliminates the majority of time bugs before they can form.
A timestamp is a fact — the exact instant something happened. A timezone is a lens for viewing that fact. Store the fact once, in UTC. Choose the lens at the last possible moment, at render.
Here's why this works. An instant in time is absolute — the moment a user clicked "submit" is the same moment everywhere on Earth. What differs is how you describe it: 3 PM in London, 10 AM in New York, same instant. The bug factory is storing or comparing times in local terms, because then "the same moment" has many different labels and they drift against each other. Store the absolute fact in UTC, keep it in UTC through your database and your API, and only bend it into a human's local timezone when you're about to show it to that specific human. Timezone becomes a display concern, which is the only place it belongs.
The mirror-image mistake — the one that produces the "date shifts by a day every save" bug — is round-tripping a date through local time on the way in and out, so each save nudges it by the offset. Keep the storage UTC and the conversion display-only, and that whole genre of bug can't happen.
Why new Date() Keeps Betraying You
The native Date object is where good intentions go to die, and it's worth being specific about why, because the failure is silent.
Date is implicitly local. The moment you parse or construct one, it quietly adopts the runtime's timezone — which is your laptop in development and the user's machine (or worse, a server in yet another zone) in production. So code that looks correct on your screen produces different results for different users, and you won't see it, because your machine only ever shows you your own timezone's answer.
// Looks innocent. Behaves differently depending on where it runs. const d = new Date('2026-07-21'); // midnight UTC... interpreted as LOCAL d.getDate(); // 21 in London. 20 in New York. Same code, different day.
That getDate() returning 20 for a New York user is the entire "off by one day" bug in miniature — the string is midnight UTC, but Date shows it through local eyes, and New York's local eyes are still on the 20th. Nothing threw an error. The value is just wrong, for some people, some of the time, which is the worst kind of wrong.
Reach for a Real Library — and Watch Temporal
Because Date is a minefield, the long-standing answer is a library that makes timezone handling explicit instead of implicit. date-fns is my usual pick — modular, tree-shakeable, and its companion date-fns-tz makes conversions something you state rather than something that happens to you:
import { formatInTimeZone } from 'date-fns-tz'; // The instant is stored in UTC. Display it in the user's zone, explicitly. const utcInstant = '2026-07-21T23:30:00Z'; formatInTimeZone(utcInstant, 'America/New_York', 'yyyy-MM-dd HH:mm'); // "2026-07-21 19:30" formatInTimeZone(utcInstant, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm'); // "2026-07-22 08:30"
Notice the same UTC instant renders as the 21st in New York and the 22nd in Tokyo — correctly, because the conversion is explicit and timezone-aware, not left to whatever Date guesses. You're stating the lens instead of inheriting it.
The genuinely good news is that the language itself is fixing this. Temporal — the new built-in date/time API — is designed specifically to end the Date era: immutable objects, explicit timezone handling, no more implicit-local footguns. It's landing in engines now, and it's the direction everything moves. If you're starting fresh, it's worth watching closely and reaching for as it stabilizes; it makes the "be explicit about timezones" discipline the default instead of something you bolt on with a library.
The Traps That Remain
Even with the UTC rule and a real library, a few sharp edges stay sharp, and they're worth naming because each has bitten real apps.
Daylight saving time means some days don't have 24 hours and some local times don't exist at all — the clock jumps forward and 2:30 AM simply never happened on that date. Any code doing time math by adding 24 * 60 * 60 * 1000 milliseconds and calling it "a day" is wrong twice a year; a real library that adds calendar days handles it, hand-rolled arithmetic does not.
"Midnight" and "today" are not absolute either — they're timezone-bound. "Show me everything from today" means a different range of instants for a user in Los Angeles than one in Berlin, and if your backend computes "today" in the server's timezone, half your users see the wrong day's data. "Today" is always someone's today, and you have to decide whose.
And serialization is where discipline leaks: the safe way to put a date on the wire is ISO 8601 in UTC (that trailing Z), because it's unambiguous. A date formatted for humans — or worse, a bare new Date().toString() — is a guessing game for whatever parses it next. Keep the API boundary UTC and ISO, and convert to human-readable strictly at the edges, at display.
How I'd Approach It
Strip it to decisions:
- Store and send UTC. Convert only for display. The single rule that prevents most time bugs.
- Distrust
new Date(). It's implicitly local, so it's implicitly wrong for someone. Don't do timezone logic with it. - Use a real library (date-fns today) and make conversions explicit. Watch Temporal and adopt it as it lands.
- Respect DST and "today." A day isn't always 24 hours, and "today" is always somebody's local today.
- Serialize as ISO 8601 UTC. Unambiguous on the wire; humanize only at the edge.
The reason time bugs are so embarrassing is that they're invisible right up until they're not. Everything works on your machine, in your timezone, during the months without a DST transition — and then a user in Tokyo, or a clock change in November, or a "today" filter computed in the wrong zone quietly serves the wrong answer, and it looks like the code was never tested. It was tested. It was just tested in one timezone. Treating time as an absolute UTC fact that you view through a local lens only at the very end is the habit that makes those 2 AM bugs stop being yours.
Next, a different kind of unglamorous-but-treacherous: file uploads in React — progress, chunking, and the surprisingly deep problem of moving a large file from a browser to somewhere it can live.
If you've got a date that shifts by a day, a badge that says "today" when it isn't, or a meeting an hour off for some users, that's almost always a UTC-vs-local leak. Tell me where the wrong value shows up and I'll tell you which conversion is doing it.