Skip to content
Kien

The check-in that moved by one day

· 4 min read

1 date · ±1 day

Travel runs on local civil dates, not instants. A check-in is a date at the property; a departure is a local time at the origin airport; a cancellation deadline is a local moment at the supplier. Store any of them as a UTC instant and something eventually renders a day early or late — for some users, on some dates, which is why it survives review.

  • dates
  • correctness
  • backend
  • integrations
On this page
  1. Two different things share one word
  2. Where it breaks
  3. Why it survives review
  4. What changed
  5. The rule

Two different things share one word

A check-in date is a fact about a building. The property says the room is yours on the 4th; that is true at the property, in the property's calendar, and it does not become a different day because the traveller is sitting in another timezone when they read it. A departure time is the same kind of fact: a local time at the origin airport. Neither is an instant on a global timeline — they are civil dates and local times, and the only place they touch UTC is when somebody converts them, usually by accident.

Some travel values genuinely are instants. "This price was quoted at 14:02:11" is a point in time. So is "the supplier confirmed the booking at". The mistake is not using instants. The mistake is using one representation for both kinds of value and hoping the difference does not matter.

Where it breaks

The first trap is a type. A date-only value goes into a column or a field whose type is a timestamp, and the moment it does, something has to invent a time and a zone for it. Usually midnight UTC. Now the check-in on the 4th is 2026-06-04T00:00:00Z, and a client rendering it in a zone behind UTC shows the 3rd. Nothing was corrupted. A time was invented, and then honoured.

The second is the word midnight. A "midnight" is only midnight somewhere. Cut-offs, day boundaries, "bookings made today" — each one silently picks a zone, and if nobody names it, the zone is whatever the process happened to be running in.

The third is the expensive one: a cancellation window computed in the server's zone. Suppliers speak JSON and SOAP/XML, and plenty of them return a deadline as a bare local string with no offset in it at all — the field says when, and the property's location says where. Parse that as if it were UTC, or as if it were the server's zone, and the free-cancellation deadline you show the traveller is hours off the one the supplier will enforce. Our own test protocol leans on this: test bookings always pick the cheapest offer with free cancellation, so a successful test can be unwound at no cost. That guarantee is only as good as the deadline arithmetic behind it.

The fourth is arithmetic. Adding 24 hours is not adding a day. Across a DST boundary a "nightly" loop produces a night that is 23 or 25 hours long, and a stay that spans one comes out with the wrong number of nights — which then prices wrong, in a way that looks like a supplier disagreement rather than a date bug.

Why it survives review

Because it is right most of the time, for most people. The engineer writing it, the test data, the staging server and the reviewer are usually all in one zone, and in one zone every one of these bugs cancels out perfectly. It fails for travellers in other zones, on dates near a boundary, on stays crossing a DST change. In a review it reads as a date being passed around. There is nothing visibly wrong to point at, which is why the question has to be asked rather than spotted.

How many bookings were ever displayed with the wrong day, I do not know. A date off by one is not an exception and does not appear on any error dashboard — it arrives, if it arrives at all, as a support ticket from someone who noticed. There is no count to publish here.

What changed

Three things, and none of them is a library.

Date-only values stay date-only end to end: date type in storage, YYYY-MM-DD on the wire, no constructor anywhere that quietly attaches a time to them. Any value that genuinely is an instant carries its zone with it rather than being normalised on arrival and reconstructed later from a guess. And conversion happens in exactly one layer — display — so that every other layer holds the value in the form the supplier meant it.

The fourth thing is documentation, and it is the one that actually prevents recurrence: next to each date field, a sentence saying which of the two it is.

The rule

Decide for every date field whether it is a civil date or an instant, and write that down next to the field. The decision takes a minute at design time. Recovering it afterwards means reading every producer and every consumer of that field and inferring what they each assumed — and they did not all assume the same thing, which is how you got here.