[Guide] Build a Ticket Booking App (Idempotent "Reserve") - implementation notes
The Ticket Booking guide frames booking as a state machine where the most important action (reserve) is safe to run repeatedly. Pair that with derived totals stored for stability, and route steps for navigation without URL churn. It's a strong posture: correctness comes from idempotence + explicit lanes, not from hoping users click once.
How did you model idempotent reserve so retries and double-clicks are safe and explainable? Did you store derived totals (fees, counts) and how did you keep them correct under partial failures? How did you implement route steps (seat picker -> summary -> payment) so back/forward replays the same contract? What evidence did you render so support can see which step/lane/intent the user is in from a screenshot?
Comments (22)
Back to latestIdempotent reserve is the only way booking UIs stay sane under real user behavior.
People refresh, double-click, lose connection, and come back. Your model has to accept that.
We treated reserve as an intent with a stable idempotency key, and we logged it like a contract transition:
txt
[tips] intent=reserve key=rsv_883 seats=2 lane=pending reason=user:click
[tips] intent=reserve key=rsv_883 seats=2 lane=ok reason=server:confirm holdId=h_19
[tips] intent=reserve key=rsv_883 seats=2 lane=ok reason=retry:doubleClick dedup=true
The third line is the whole point. It turns "double click" from a bug into a normal story.
Counterpoint: idempotence can hide UX problems if you rely on it too much.
We still disabled the button while pending and showed a lane chip. Idempotence is correctness; UX still matters.
Yes. Idempotence is insurance, not an excuse.
The UI should still guide the user into the happy path, and evidence should explain what happened when they stray.
Derived totals stored were necessary because the seat picker and summary both need the same numbers.
If they compute independently, you get the trust-killer: totals change between steps with no explanation.
Long-form: booking failures are rarely pure errors; they're partial success with timing.
Seat held but payment failed. Payment pending but confirmation delayed. Idempotent reserve makes those partial states representable.
The UI should render lanes for each phase (hold, payment, confirm) and the logs should match.
Route steps were great because they let back/forward replay the stepper contract without forcing new URLs. We log step transitions so they aren't mysterious:
txt
[tips] step=seats -> step=summary reason=user:next
[tips] step=summary -> step=payment reason=user:next
[tips] step=payment -> step=summary reason=backWe rendered evidence for step + lane + idempotency key so screenshots were actionable.
It printed in debug mode like step=payment reserveLane=pending reserveKey=rsv_883.
Counterpoint: storing derived totals can become stale if you forget to re-derive after a seat change or fee update.
We enforced a single mutation helper that always triggers derive, and we log derive reasons.
We treated reserve as safe-to-repeat, but we still needed a story for race conditions (two tabs). We logged conflicts explicitly so support didn't have to guess:
txt
[tips] intent=reserve conflict key=rsv_883 winner=tab:A loser=tab:B reason=server:dedupDocs tie-in: idempotent actions are a core concept story (repeatability) more than a networking story.
Related: Core Concepts (Best Practices) and State Management (Best Practices).
We also stored derived "reservation story" as a string that includes last intent and lane.
It sounds cheesy, but it made support tickets readable.
Long-form counterpoint: idempotence can make teams complacent about monitoring because failures are "safe".
But safe failures are still failures, and users still feel them. We logged lane durations and treated long pending as a product problem.
We also had to make sure reserve key generation was deterministic per selection, not per click.
Otherwise you end up with many keys for the same intent and dedup can't work.
Counterpoint: sometimes the best UX is to let users change seats without tearing down the whole reserve flow.
We treated seat changes as a new reserve intent but kept the stepper stable so it didn't feel like a reset.
Idempotent reserve also made it easier to write tests: tests can click twice and assert nothing breaks.
That seems trivial, but it's a strong signal that your model matches user reality.
We treated payment as a separate lane that can fail without invalidating the hold lane.
Separating lanes prevented the classic bug: payment failure resets seat selection silently.
Long-form: this guide is basically "make booking observable".
If a user says "I clicked reserve", the app should be able to say what intent it recorded, what lane it's in, and what it will do next. That's the contract.
We also derived totals in one place and used them everywhere, including confirmation emails.
If emails and UI disagree, trust is destroyed. Derived totals as contract prevented that drift.
Counterpoint: seat pickers are inherently interactive; trying to force them into a rigid stepper can feel clunky.
We allowed some step skipping but still kept the lane/evidence story consistent.
We also logged "reserve replay" on refresh so it's clear why the UI restored a pending state:
txt
[tips] replay intent=reserve key=rsv_883 lane=pending reason=refreshIf you implement this, start by writing your lanes down (hold, payment, confirm) and decide what evidence each lane produces.
Then code it. Otherwise the booking flow becomes a pile of special cases.
The idempotent framing made the app feel professional.
Users do weird things; professional systems treat weird things as normal paths, not as exceptions.