05Oct. 2025Sole developerArchived
Seat booking
75 seats, two languages, one spreadsheet.
A bilingual seat reservation system for a real event, built on Google Apps Script with a Google Sheet as the database. Concurrent bookings are serialised with a script lock so two people pressing the same seat cannot both get it, and the organisers could read the bookings in a tool they already knew.
- JavaScript
- Google Apps Script
- Google Sheets
- HTML5
- CSS3
The problem
An event needed seat reservations. 75 seats, gender-segregated sections, attendees who would use an Arabic interface and attendees who would use an English one, and organisers who needed to see who had booked what.
The manual version of this is a shared spreadsheet and a WhatsApp group, which fails in a specific and predictable way: two people claim the same seat within a few seconds of each other, and nobody finds out until they are both standing in front of it.
The constraints
No budget. Not a small budget, none. That ruled out paid hosting, a managed database, and anything with a monthly cost for an event lasting one day.
A hard deadline, because the event date was not moving.
And a requirement I did not appreciate the weight of at first: the organisers had to be able to read the bookings themselves. Not through a dashboard I would build and support, and not by asking me. They needed to sort, filter, print a list and hand it to someone at a door.
That last constraint decided the architecture more than the other two combined.
What I built
A single HTML file with an interactive seat map, and a Google Apps Script backend writing to a Google Sheet.
The map lays out 75 seats: left columns L1 through L4 with 8, 6, 6 and 5 seats, right columns R1 through R4 with the same, and centre rows A through D with 3, 6, 8 and 8. Men book the right columns; women book the left columns and the centre rows. The interface is fully Arabic and English with the layout direction following the language.
Selecting an available seat opens a booking form asking for name, college and phone. Seat availability updates by polling. Cancelling means supplying your name, phone or college and the system finds your booking.
The backend exposes booking status, seat listing, reserve and cancel, plus management functions for seeding and syncing the seat inventory.
What this is and is not
The public demo has no backend attached. It renders the map and the booking flow so the interface can be seen. Nothing is stored.
There are no user accounts. Attendees do not log in. A booking is identified by the details
entered at the time, and cancelling means supplying one of them. Administrative functions
are protected only by the fact that running them requires access to the Apps Script project
itself. There is no authentication anywhere in Code.gs.
Gender sections are enforced in the browser and nowhere else. Code.gs takes no gender
parameter, so the rule is frontend validation rather than a constraint the backend holds.
Access control is the spreadsheet's sharing settings. Whoever can open the sheet can read every attendee's name and phone number. For a one-off student event where the organisers already had that list on paper, that was an acceptable trade. It would not be acceptable for anything larger or anything recurring, and it is the first thing I would change.
The backend code is in the repository but the deployment is not reproducible from it. You would need to create your own Apps Script project, run the setup function to generate a sheet, and deploy it as a web app.
I would rather state all of this than let someone assume there is an auth system here. There is not. There is a spreadsheet with careful sharing settings, and the interesting engineering is somewhere else entirely.
The technical decisions
| Decision | Reasoning |
|---|---|
| Google Sheets as the database | The organisers can read, sort, filter and export bookings without any interface I have to build or support. Postgres would have been the better technical choice and the worse choice for these people. Optimising for the operator rather than the engineer was correct here |
| Google Apps Script as the backend | Free, nothing to keep alive, and it runs next to the data. Paying for hosting for a one-day event was not justifiable |
Both attendee-facing writes inside LockService.getScriptLock() |
Apps Script has no transactions. A script lock is the only primitive that makes read-then-write safe, and without it two simultaneous bookings both read AVAILABLE and both write. reserveSeat and cancelBooking take it; the administrative seed and sync functions do not, because one person runs those from the editor |
tryLock(5000) rather than blocking indefinitely |
It returns false after five seconds and the user gets "System busy, please try again". A hung page with no explanation is worse than a clear failure the user can retry |
The lock released in a finally block |
A thrown error inside the critical section must not leave the lock held. Everything else in the system stops if it does |
| The availability check inside the lock, not before it | Checking first and locking second is still a race, just a narrower one, and narrower races only fail under the load you get in the first minute after booking opens |
Seat row index cached with CacheService |
A map of seat id to row number, cached 600 seconds. Without it every booking scans column A while holding the lock, and Apps Script charges in wall-clock time for spreadsheet reads |
| A reservation is one batched write | getRange(row, 2, 1, 5).setValues([[...]]) writes five columns in one call. Five separate writes would be five round trips with the lock held, and a failure partway through leaves a half-booked row |
| Ambiguous cancellations refused, not guessed | If a name matches more than one booking, the system returns the matching seat ids and cancels nothing. Cancelling a stranger's seat because two people share a name is not recoverable at an event |
| Gender rules live in the frontend only | isSeatAllowedForGender() is in index.html. Code.gs takes no gender parameter and checks nothing, so a crafted call bypasses the rule. I am listing this as a decision because it was one, and it was the wrong one. It held because the only client was the page I shipped, which is an assumption rather than a defence |
The hardest thing
The double booking problem, which I did not initially believe I had.
Apps Script gives you a spreadsheet and functions. There is no transaction, no SELECT FOR UPDATE, no unique constraint you can lean on. My first version read the seat's status,
checked it was available, and wrote the booking. Tested alone, it was flawless.
Then I thought about what happens in the first thirty seconds after booking opens, which is
when everyone arrives at once. Two requests read the same row, both see AVAILABLE, both
decide to proceed, and both write. The second overwrites the first. Nothing errors. The
sheet ends up consistent-looking and wrong, and the person who lost their seat finds out at
the door.
I could not reproduce it reliably by clicking, which is the trap. The window is milliseconds wide and absent under manual testing, so the evidence that it was real was an argument rather than a failing test.
LockService.getScriptLock() is the answer, and the important detail is what goes inside
it. The lock has to cover the read, the decision and the write as one unit. I got this
wrong first: I locked around the write only, which does nothing, because the stale decision
was already made outside the lock.
The second problem the lock created was performance. Holding a lock while scanning a column
for a seat's row means every other booking waits for that scan. So the lock forced the
cache: getSeatIndex_ builds a map of seat id to row number, caches it for ten minutes, and
turns the lookup into a hash access. The single batched setValues write is the same
reasoning. Everything inside a critical section is time everyone else spends queueing, so
the work in there should be as small as it can be.
The framing I took away is that a lock is a cost you pay per contended operation, so lock scope and lock duration are separate problems. Get the scope wrong and it is incorrect. Get the duration wrong and it is correct but slow at exactly the moment it matters.
What I would do differently
There is a real inefficiency in the cancellation path. The loop reads the entire data block
in one getRange call, then calls getRange(rowIndex, targetCol).getValue() again inside
the loop for the comparison column. That is an API round trip per row, with the lock held,
against data already sitting in memory. At 75 rows it is survivable. It is also exactly the
mistake I had just finished designing around in the booking path, which is why I did not
see it, and I only noticed rereading the code later.
I would add an append-only log sheet. Cancelling currently clears the row, so there is no record a seat was ever held. There is no way to answer "who had C4 before" or resolve a dispute.
I would pass gender to reserveSeat and check it in Code.gs, so the section rule is
enforced rather than suggested. As shipped it is frontend validation described as a rule,
and the gap between those two things is the sort of thing I would rather find myself than
have someone find for me.
I would move bookingOpen into Script Properties. It currently requires a code edit and a
redeployment, which is the wrong ergonomics for the one setting an organiser might need to
change at short notice on the day.
And I would treat the sheet's sharing settings as the security boundary they are rather than as a deployment detail, which mostly means writing down who has access and why.
What came of it
It ran for its event and real attendees used it to book real seats, which makes it the only thing I have built that strangers depended on. I did not instrument it, so I have no numbers and no record of how it behaved under the opening rush. The concurrency work was insurance against a failure I cannot prove was ever tested, which is the honest version and also the normal one.
The Apps Script redeployment friction was the thing I complained about at the time, and the concurrency work is the thing I would actually talk about now. It is the only project I have where the interesting problem was correctness under simultaneous access rather than features, and it is the one where the technically inferior choice was clearly the right one, because the people who had to live with the system after I left could operate it without me.