Three bugs hiding in one small feature
SkinAtlas shows check-in cards at milestones: day 7, day 15, day 30 after you start logging, then on a recurring schedule after that. Free accounts get a monthly cadence, Pro gets weekly. Small feature, maybe a day of work. It shipped with three separate bugs, and what makes them worth a note is that each one needed a different kind of fix.
The design flaw
The function that computed the current milestone slot didn’t know about plans. It returned a monthly slot key for everyone. A Pro user on weekly cadence would generate the same slot key as a free user, the duplicate check would fire, and their check-ins silently stopped after day 30.
The tempting fix is a patch at the call site: check the plan before calling,
adjust the key after. The real fix was admitting the function had the wrong
shape. It became currentPeriodSlot(plan), cadence lives inside it, and the
tests now pin the weekly key format so it can’t quietly regress. When a bug
comes from a function not knowing something it needs to know, the fix is to
teach the function, not to whisper hints around it.
The serialization trap
Next.js’s unstable_cache runs your value through JSON on the way to the
cache, and JSON has no Date type. Dates go in as objects and come out as
ISO strings. The slot detection code called .getTime() on what it assumed
was still a Date, and the home page crashed for exactly the users whose
data was cached, which is the worst kind of bug: invisible in development,
where the cache is cold every time.
The fix rehydrates the Date fields after the cache read. The lesson is about caching in general: a cache boundary is a serialization boundary, and anything that crosses it comes back subtly different. Types lie at that boundary unless you make them tell the truth.
The race
Two browser tabs, same user, both loading the home page at once. Both notice a milestone is due, both try to insert the same check-in row, and one crashes on the unique constraint. Classic and boring, and I got to watch it happen in production logs.
The fix is one word: upsert instead of create. The race loser becomes
a no-op instead of an error. There’s a whole family of concurrency bugs in
server code where the honest answer is not a lock or a queue but a database
operation that’s already idempotent.
One feature, three lessons
None of these bugs were exotic. What strikes me looking back is that they lived in maybe sixty lines of code, and no amount of staring at those sixty lines would have caught the second one, because the trap was in the framework’s cache layer, not in anything I wrote. Testing found the first, production logs found the third, and a crash report found the second. Different bugs surface through different instruments. You need all of them running.