<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Postgres on aottr</title><link>https://aottr.dev/tags/postgres/</link><description>Recent content in Postgres on aottr</description><generator>Otter</generator><language>en-us</language><copyright>AlexOttr</copyright><lastBuildDate>Fri, 03 Apr 2026 15:33:18 +0200</lastBuildDate><atom:link href="https://aottr.dev/tags/postgres/index.xml" rel="self" type="application/rss+xml"/><item><title>Designing a Fair Booking System Is Harder Than It Looks</title><link>https://aottr.dev/posts/2026/04/designing-a-fair-booking-system-is-harder-than-it-looks/</link><pubDate>Fri, 03 Apr 2026 15:33:18 +0200</pubDate><guid>https://aottr.dev/posts/2026/04/designing-a-fair-booking-system-is-harder-than-it-looks/</guid><description>&lt;p>Imagine you’re organizing an event and open the registration.&lt;/p>
&lt;p>100 users want to get a room at the same time.
You have 30 rooms&amp;hellip;What could go wrong? &amp;hellip; &lt;em>a lot&lt;/em> ^^&lt;/p>
&lt;h2 id="the-problem">The Problem&lt;/h2>
&lt;p>As an urgent request from a local convention, I was asked to write a hotel booking system with a few constraints:&lt;/p>
&lt;ul>
&lt;li>Limited number of rooms (~500 in total)&lt;/li>
&lt;li>Different room tiers (with a quota per tier)&lt;/li>
&lt;li>User picks a tier and gets assigned a room&lt;/li>
&lt;li>Allocation should feel fair (FCFS, not a lottery)&lt;/li>
&lt;li>The system should respond quickly&lt;/li>
&lt;/ul>
&lt;p>&lt;em>At first glance, this might sound simple.&lt;/em>&lt;/p>
&lt;h2 id="the-very-naive-approach">The (very) Naive Approach&lt;/h2>
&lt;p>The most straightforward implementation looks like this:&lt;/p>
&lt;ol>
&lt;li>Query for a room that is not occupied yet &lt;code>(user_id = NULL)&lt;/code>&lt;/li>
&lt;li>Assign it to the user&lt;/li>
&lt;li>Done&lt;/li>
&lt;/ol>
&lt;p>I hope it does not need to be explained why this is not a great idea. The query to get a room will with a high confidence return the same room id for parallel requests.&lt;/p>
&lt;p>The race condition appears when both users get the same room as response and try to claim it.&lt;/p>
&lt;h2 id="the-still-naive-approach">The (still) Naive Approach&lt;/h2>
&lt;p>An &lt;em>improvement&lt;/em> of this implementation would be some kind of randomization logic:&lt;/p>
&lt;ol>
&lt;li>Query available rooms&lt;/li>
&lt;li>Pick one randomly&lt;/li>
&lt;li>Assign it to the user&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Why does this still cause an issue with concurrency?&lt;/strong>&lt;/p>
&lt;p>Two users can still pick the same room, especially with decreasing number of available quota, what the possibility per room significantly increases.&lt;/p>
&lt;p>The race condition still applies and the user gets a failing request.&lt;/p>
&lt;h2 id="the-obvious-approach-use-a-queue">The “Obvious” Approach: Use a Queue&lt;/h2>
&lt;p>The thought process was simple:&lt;/p>
&lt;blockquote>
&lt;p>Let’s just serialize the booking process.&lt;/p>
&lt;/blockquote>
&lt;p>Since I was writing my backend with NestJS, I considered writing a queue system with e.g. BullMQ:&lt;/p>
&lt;ul>
&lt;li>Each booking requests gets queued&lt;/li>
&lt;li>Workers process them one by one (FIFO)&lt;/li>
&lt;li>no race conditions, because the room selection happens in the worker task&lt;/li>
&lt;/ul>
&lt;p>Problem solved? Not really&amp;hellip;&lt;/p>
&lt;p>Te queue &lt;em>did&lt;/em> fix consistency, but it introduced a long latency. Sizing a queue is difficult enough, but maintaining a level of fairness made it feel impossible.&lt;/p>
&lt;p>&lt;strong>The direct problem here:&lt;/strong> There are different room tiers, cheaper rooms, more expensive rooms, two beds, three beds, King size, Queen size etc…&lt;/p>
&lt;p>While one &lt;strong>user A&lt;/strong> might click on a room and wait for his position in the queue to pass, another &lt;strong>user B&lt;/strong> might take another room tier and get assigned instantly. While being in the queue, the room tier of &lt;strong>user A&lt;/strong> gets sold out before he got a spot, leaving him with no room and a redirect to the room selection.
&lt;strong>User A&lt;/strong> might have been fine with the same room tier as &lt;strong>user B&lt;/strong>, but since the feedback, that the original room tier was sold out took multiple seconds, this possibility to switch might be gone now too.&lt;/p>
&lt;p>Queues are a wonderful tool to de-couple workloads while being able to maintain a feedback loop about the progress. But queue sizing is not easy and it is not reliable to get immediate response.&lt;/p>
&lt;h2 id="the-real-problem">The “real” Problem&lt;/h2>
&lt;p>This is where the interesting part began, and it already sounded over-engineered for the requester…&lt;/p>
&lt;p>This booking system did not just need optimization in one specific direction. It needed a &lt;strong>balance of three competing goals:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Consistency&lt;/strong> — no double bookings of one room&lt;/li>
&lt;li>&lt;strong>Latency / Feedback&lt;/strong> — fast responses of success / failure&lt;/li>
&lt;li>&lt;strong>Fairness&lt;/strong> — equal chance to get a room (in general)&lt;/li>
&lt;/ul>
&lt;p>Imagine one of these Radar charts, one goal pulling on two others and the solution is to find a middle-ground.&lt;/p>
&lt;h2 id="rethinking-the-approach">Rethinking the Approach&lt;/h2>
&lt;p>Instead of pushing the problem into a queue, I moved the logic closer to the database. &lt;em>I use postgres btw.&lt;/em>&lt;/p>
&lt;p>&lt;strong>The idea:&lt;/strong> &lt;em>Do selection and assignment atomically inside a transaction&lt;/em>&lt;/p>
&lt;ol>
&lt;li>Start a transaction&lt;/li>
&lt;li>Select a random available room&lt;/li>
&lt;li>Lock it&lt;/li>
&lt;li>Assign the User&lt;/li>
&lt;li>Commit&lt;/li>
&lt;/ol>
&lt;p>Inside the transaction, I select a room with the following statement:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">WITH&lt;/span> available_rooms &lt;span style="color:#66d9ef">AS&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">SELECT&lt;/span> r.id &lt;span style="color:#66d9ef">FROM&lt;/span> &lt;span style="color:#e6db74">&amp;#34;Room&amp;#34;&lt;/span> r
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">LEFT&lt;/span> &lt;span style="color:#66d9ef">JOIN&lt;/span> &lt;span style="color:#e6db74">&amp;#34;RoomBooking&amp;#34;&lt;/span> rb &lt;span style="color:#66d9ef">ON&lt;/span> r.id &lt;span style="color:#f92672">=&lt;/span> rb.&lt;span style="color:#e6db74">&amp;#34;roomId&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">WHERE&lt;/span> &lt;span style="color:#e6db74">&amp;#34;roomCategoryId&amp;#34;&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#960050;background-color:#1e0010">${&lt;/span>roomCategoryId&lt;span style="color:#960050;background-color:#1e0010">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">AND&lt;/span> rb.&lt;span style="color:#e6db74">&amp;#34;roomId&amp;#34;&lt;/span> &lt;span style="color:#66d9ef">IS&lt;/span> &lt;span style="color:#66d9ef">NULL&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">ORDER&lt;/span> &lt;span style="color:#66d9ef">BY&lt;/span> id &lt;span style="color:#66d9ef">ASC&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">SELECT&lt;/span> id &lt;span style="color:#66d9ef">FROM&lt;/span> available_rooms
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">LIMIT&lt;/span> &lt;span style="color:#ae81ff">1&lt;/span> 
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">OFFSET&lt;/span> FLOOR(RANDOM() &lt;span style="color:#f92672">*&lt;/span> (&lt;span style="color:#66d9ef">SELECT&lt;/span> &lt;span style="color:#66d9ef">COUNT&lt;/span>(&lt;span style="color:#f92672">*&lt;/span>) &lt;span style="color:#66d9ef">FROM&lt;/span> available_rooms))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">FOR&lt;/span> &lt;span style="color:#66d9ef">UPDATE&lt;/span> SKIP LOCKED;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This was followed with an update statement on &lt;code>Room&lt;/code> and the creation of a &lt;code>RoomBooking&lt;/code> for the user inside the same transaction.&lt;/p>
&lt;h2 id="verdict">Verdict&lt;/h2>
&lt;h3 id="benefits">Benefits&lt;/h3>
&lt;ul>
&lt;li>Low latency (No queue -&amp;gt; the user gets immediate feedback)&lt;/li>
&lt;li>Strong consistency (Row-level locking prevents double booking)&lt;/li>
&lt;li>Reasonable fairness (Random selection by offset distributes users across available rooms)&lt;/li>
&lt;li>Simple architecture (more complex than the naive approach but no queues to maintain)&lt;/li>
&lt;/ul>
&lt;h3 id="tradeoffs">Tradeoffs&lt;/h3>
&lt;ul>
&lt;li>The database becomes the bottleneck under heavy load&lt;/li>
&lt;li>Scaling beyond a certain point requires re-thinking the model&lt;/li>
&lt;li>Still handling with “random” fairness at the room selection&lt;/li>
&lt;/ul>
&lt;p>But for this project with around 1000–1500 users and a few hundreds of rooms, the balance was far better than a queue.&lt;/p>
&lt;h3 id="what-id-improve-next">What I’d improve next&lt;/h3>
&lt;p>If we decide to keep this project up for the growing convention, I’d consider:&lt;/p>
&lt;ul>
&lt;li>a better fairness strategy (not just random)&lt;/li>
&lt;li>tier-aware allocation logic with &amp;ldquo;fallback choice&amp;rdquo;&lt;/li>
&lt;li>retry strategies with back-off&lt;/li>
&lt;li>Observability (how often do we fail and when?)&lt;/li>
&lt;li>Possible hybrid-approaches (light queuing under pressure)&lt;/li>
&lt;/ul>
&lt;h2 id="final-thoughts">Final Thoughts&lt;/h2>
&lt;p>The biggest lesson here wasn’t about SQL transactions or queues. Locking-mechanisms are basics in concurrency.&lt;/p>
&lt;p>It was this:&lt;/p>
&lt;blockquote>
&lt;p>Fixing race conditions is easy.&lt;/p>
&lt;p>Designing a system that feels fair and snappy is not.&lt;/p>
&lt;/blockquote>
&lt;p>And sometimes, the most “sophisticated” solution is not the best one…&lt;/p></description></item></channel></rss>