Here’s the setup that felt airtight.
Freebo’s public checkout page shows the available time slots for a yacht — pick a date, see what you can book. It’s the hottest surface in the app: every operator’s customers hit it. Computing those slots live on every page load was cheap but not free, so I put a cache in front of it. Same input, same output, faster.
To trust the cache I wrote a parity test. It called getMergedSlots(cache) and getMergedSlots(live) with the same arguments, diffed the JSON, and asserted equality. Green. Every run. For weeks.
Then I ran real end-to-end validation — the actual HTTP route, hit like a real user. And a slot showed up on June 28 that should have been on June 29.
The one-day shift
Central Time is behind UTC by five or six hours depending on DST. When you take a bare date string like 2026-06-29 and parse it as UTC, midnight UTC on the 29th is 6 or 7 PM on the 28th in Chicago. If your “give me the days between these dates” loop then normalizes to local time, congratulations — you just walked backwards a day.
Here’s the exact fix in flex-duration.ts:
const startUtc = DateTime.fromISO(req.startDate, { zone: 'utc' });
const endUtc = DateTime.fromISO(req.endDate, { zone: 'utc' });
if (!startUtc.isValid || !endUtc.isValid) return [];
const startLocal = startUtc.setZone(req.timezone).startOf('day');
const endLocal = endUtc.setZone(req.timezone).startOf('day'); const startLocal = DateTime.fromISO(req.startDate, { zone: req.timezone }).startOf('day');
const endLocal = DateTime.fromISO(req.endDate, { zone: req.timezone }).startOf('day');
if (!startLocal.isValid || !endLocal.isValid) return []; Two lines. That was the bug.
Why the parity test missed it
Both sides of the parity check ran through the same flex-duration strategy. The cache path called it. The “live” comparison path called it. So both sides shifted the day the same way, produced the same broken output, and matched byte-for-byte.
I wasn’t testing “does the cache match the truth.” I was testing “does the cache match the other version of the bug.”
The tell: my parity test lived one layer above the buggy function. The bug lived one layer below both callers. If your cache and your reference implementation share a subroutine, the shared subroutine is invisible to any diff between them. It’s a blind spot the shape of every function they both import.
Once I moved the comparison up to the actual route handler — the same entry point a browser hits — the mismatch fell out on the first run. Cache says one set of slots, route says another. That was catchable. I kept that route-level check as the canonical validation and demoted the internal parity test to a spot check.
The rule I keep re-learning: your test needs to enter your system where your users enter it. If it enters two levels down, everything below that entry point is a shared assumption between “cache” and “live,” and shared assumptions are exactly the kind of thing you’re not going to catch when they’re wrong.
The other thing the cache was quietly wrong about
While I was in there I noticed the cache key was missing a dimension. The key was (location, product, date, duration, booking_type). Fine for most yachts. But some Freebo products have guest-scoped availability rules — “if the party is over 20 people, block these time windows, we don’t have the crew.”
That means the same date + product + duration can legitimately return different slots depending on how many guests you ask about. And the cache was serving whichever answer landed first.
Party of 8 loads the page at 9 AM. Cache stores those slots. Party of 30 loads at 9:01 AM and gets served the party-of-8 answer — which includes a couple of slots that were supposed to be blocked for a group that size.
Nobody had complained. It hadn’t turned into a booking. But it was one edge case away from an ugly support ticket: “we booked at 3 PM, showed up, the crew said we couldn’t take a group this size at that time.” So guests became the 6th key dimension.

For products that don’t have guest-scoped rules, the key still passes 0 as a sentinel so those cache hits stay dense across group sizes. You only pay for the extra dimension when you’re actually keying on it.
Two lessons stitched together
-
Parity tests that live below the caller share bugs with the caller. If cache and live both go through the same buggy function, they’ll agree. Test entering the system at the top, not two floors down where they share plumbing.
-
Your cache key is a promise about what varies the output. Every time a “quiet” input turns out to affect the answer, your key gets a new column. If you don’t add it, you’re serving cross-contaminated results to different users. Nobody notices until they do.
The cache is now about 4x faster than the live path on a warm hit. Byte-identical to the route output. Keyed correctly. And the test that validates it enters through the same door the customer does.
The fix took two lines. Admitting the parity test had been lying to me for weeks took longer.