At Cuty a salon's slot can hold at most one booking — sounds simple. But in reality two users can hit "Confirm" on the same slot within milliseconds, or one of them can be mid-payment when the other arrives. The system has to make a clear decision about who wins, who loses, and what each of them sees.
Shape of the problem
Optimistic vs pessimistic locking is often presented as an academic question. At Cuty it is a real UX design problem. Telling someone "sorry, somebody else got this slot" two seconds after they thought they were almost done erodes their trust in the product. Speed matters as much as a clear, intelligible flow.
We work with three constraints:
- Data consistency: The same slot cannot be assigned to two people — non-negotiable.
- UX: We need to inform the loser quickly and present alternatives.
- Payment latency: We must hold the slot for a reasonable time while the user enters their card details — but not forever.
Our approach: staged reservation
An appointment booking in Cuty has three stages:
- Soft hold(5 minutes): When the user says "I want this slot", a row
{slot_id, user_id, expires_at}is created in Postgres withexpires_atset to 5 minutes ahead. During this window no other user can pick the same slot. - Confirm: When payment succeeds, the soft hold flips to "confirmed". The cancellation window depends on local regulations.
- Garbage collection: A background job sweeps rows whose
expires_athas passed without being confirmed; the slot becomes available again.
Who wins the race?
We rely on a UNIQUE constraint on slot_id when creating the soft-hold row. In Postgres this is INSERT ... ON CONFLICT DO NOTHING. Two users hit it in the same second; one writes, one is rejected. The rejected user gets a clear "just taken — here are three alternatives" message, never a silent failure.
Why not optimistic?
Optimistic locking assumes contention is rare. At Cuty's peak slots (Saturday 6 PM, say) contention is high. Instead of a pessimistic SELECT FOR UPDATE we picked UNIQUE + short soft hold because:
- Pessimistic locks hold a row lock for the duration of the transaction; that blocks other users for as long as the transaction lives.
- UNIQUE constraint with INSERT is atomic and fast; the transaction is measured in milliseconds.
- Soft hold lets the UI return "you got it" immediately and gives the payment flow breathing room.
Edge cases
Payment timeout: What if the user spends more than 5 minutes on payment? A frontend timer warns at 4 minutes; at 5 minutes we release the slot. If a payment is in flight at the provider but not yet settled, idempotency keys prevent a double booking.
Server crash: The garbage-collection sweeper runs every minute. The slot is never indefinitely locked due to a server fault.
Time zones: All expires_atvalues are stored in UTC; only the UI converts. This stays correct across Türkiye's seasonal time changes.
Conclusion
A "simple" appointment-conflict problem at Cuty turns out to be a coordination problem across UI, backend, database and payment provider. Our answer: let the database make the canonical decision via UNIQUE, give UX air with a short soft hold, and let the system self-heal with garbage collection.
We will validate this architecture once Cuty hits TestFlight beta. With real-world data we will revisit which assumptions held, and which ones needed revision — back here on the blog.