If you build order-tracking UI, you have almost certainly done this:
new Date(scan.timestamp).toLocaleString()
It looks correct. It is the single most common way tracking pages end up lying to customers, and the bug report you get back will not look like a timezone bug. It will look like this, which is a real support message we got this week:
"This morning my package said delivered at 21:49. This was at 6:04 am. It was not delivered to my house."
Two different complaints are tangled together there, and only one of them is the carrier's fault.
A scan is a local wall-clock reading, not an instant
A tracking event is produced by a device in a facility, or in a driver's hand, writing down the time where the scan happened. Carriers publish that wall-clock reading. Most of them do not publish an offset with it.
So the string 2026-09-30 21:49:00 arriving in your pipeline is not an instant in time. It is "the clock on the wall at whichever building scanned this said 21:49". Without the offset you cannot convert it, and if you treat it as UTC and then render it in the viewer's zone, you have invented a number nobody published.
The failure mode is nasty because it is plausible. Attach Z to a naive string, render in the browser's zone, and a 09:57 delivery in Germany becomes 16:57 for a viewer in Vietnam. Nothing throws. The page just shows a time that never existed, and the customer - who was standing in their hallway at 09:57 - concludes your tracking is broken.
What we do instead
We print the digits the carrier published, unconverted:
// The stored value is the carrier's wall clock with no offset.
// Reading it back with UTC getters returns the original digits unchanged.
const dt = new Date(value)
return `${dt.getUTCFullYear()}-${pad2(dt.getUTCMonth() + 1)}-${pad2(dt.getUTCDate())} `
+ `${pad2(dt.getUTCHours())}:${pad2(dt.getUTCMinutes())}`
Using UTC getters on a value you only ever tagged as UTC is not a hack, it is the inverse of the tag. You get back exactly what the carrier said. The rule that follows:
Never convert a timestamp whose offset you were never given. Render it as text and label whose clock it is.
If you do have a genuine offset - some carriers send one, and your own events like "we last checked this" are real instants - convert those freely. Just keep the two classes apart in your schema. One is an instant. The other is a string that looks like one.
Then handle the scans that have no clock at all
Once you stop converting, you notice the second problem: not every scan has a time.
Measured on delivery scans flowing through our platform (sample of ~27,000 recent delivery events):
| share | |
|---|---|
| carries a time of day | ~96% |
| date only, no clock | ~4% |
Four percent sounds small until it is the one parcel a customer is angry about. Those arrive as 09/18/2026 with nothing after it. new Date() will happily give you midnight, your formatter will print 00:00, and you have just told someone their parcel was delivered at midnight.
// Treat "no clock" as a distinct state. Do not let a parser invent 00:00.
const HAS_CLOCK = /\d{1,2}:\d{2}/
if (!HAS_CLOCK.test(raw)) return { date: parseDateOnly(raw), time: null }
Render that as the date alone. A missing time is information: it tells the customer the carrier never published a minute, so there is no minute to argue about.
Watch the format spread while you are in there. In the same sample the clock-bearing values came in at least two shapes - YYYY-MM-DD HH:MM:SS and July 20, 2026 9:00 PM. If you regex for one, you will silently classify the other as "no time". I did exactly that in the first pass of this measurement and got 72% instead of 96%; the 12-hour strings were hiding in the remainder.
Do not build "is this late?" on the hour either
The last trap: once you have hours, it is tempting to flag odd ones. Resist.
Delivery scans in that same sample, by hour of day (carrier local):
- Busiest window is late morning to mid-afternoon, roughly 8-9% of deliveries per hour
- Between 20:00 and 05:59: about 9%, or roughly 1 in 11
A 21:49 delivery scan is not an anomaly worth surfacing as one. Contract and gig drivers finish the route they were assigned, and in peak season that runs later. If your UI puts a warning badge on evening deliveries, you will generate support tickets for one parcel in eleven, every day, forever.
The part that actually helps the customer
Having removed three ways to be wrong, here is the one field worth promoting in the UI: the location on the delivery scan, not the minute.
"Delivered" with the customer's town on it is a different claim from "Delivered" at a depot, a locker, or an access point. That distinction tells the customer whether to look on their porch or to go ask the seller to open an investigation. The timestamp, converted or not, never tells them that.
So: store the offset-less string as a string, label whose clock it is, treat "no clock" as its own state, don't badge evening deliveries, and put the scan location where the eye lands first.
I work on 24hTrack (24htrack.com), a free package tracker for 3,200+ carriers - paste any tracking number, the carrier is detected automatically, no sign-up. The percentages above come from delivery scans on our own platform. There is a longer, non-technical version of this as an open guide (CC BY).
Top comments (0)