A class capacity limit is one number on one row, and one write has to be the referee.
To limit class size in an app you store a maximum number of attendees on each dated session, keep the count of confirmed bookings next to it, and let a single conditional write decide whether the next booking fits. That last part is the whole job. Counting the bookings and then inserting one works perfectly until two people tap at the same second, and then you have thirteen people and twelve mats, with no error anywhere to tell you. Most teams discover it on the busiest class of the week. This page is for whoever is building an appointment calendar app and has just reached the part where places run out.
What follows is where the number belongs, what the database actually promises when two bookings race, how Apple expects you to take the money for a class, and what to do once the class is full. Figures and quotations come from PostgreSQL's and Apple's own documentation, read on 14 October 2026.
Start hereThe short version
Let the database refuse the booking, not your code.
A capacity limit that reads the current count, compares it in your app, and then inserts a booking is two operations with a gap in the middle. Every overbooked class you have ever heard about lives in that gap. The fix is to collapse the check and the write into one statement that the database either applies or does not, and to treat the refusal as a normal answer rather than a failure.
Everything else about class capacity is business judgement, and most of it is easier than it looks. Capacity belongs on the dated session, because rooms and kit change. A waitlist is worth building the moment you see cancellations. Desk bookings have to write to the same table as the app. And the money for a class people attend in person does not go through in-app purchase, because Apple's guidelines say it must not.
Where does the capacity number actually live?
On the session, meaning the dated occurrence someone can book, not on the class it was generated from. The class is a template: name, length, instructor, the usual size. The session is Tuesday the twenty first at six, in the small studio, with eleven working machines instead of twelve. If capacity sits on the template, you either edit the template and quietly rewrite what last month looked like, or you start keeping a list of exceptions. Copy the number onto each occurrence when you generate the schedule, and let staff change one occurrence without touching the rest.
Then pick how a place is represented, because this decides what the database can enforce for you later. A counter is one column on the session row, incremented as bookings arrive, and it is cheap and easy to lock. Seat rows are one row per place, taken or free, which costs more writes but lets a unique index do the refusing. The PostgreSQL documentation states the outcome plainly: where a unique index is present and a duplicate row is concurrently inserted, a uniqueness violation error is raised. An error on the thirteenth booking is a much better outcome than a thirteenth person.
Decide what counts against the limit before you write any of it down. The instructor's own mat. A place held for a review visit or a trial. A machine out of service for a fortnight. A parent who stays in the room. A drop-in paid in cash at the desk. Each one takes a place out of the room whether or not your app knows about it, so each one needs a way into the same table, ideally marked as a hold or a desk booking so your reporting stays honest. A separate spreadsheet behind the counter is the most common cause of an overbooked class, and no amount of locking fixes it.
Last, decide what the customer sees. Showing the exact number left on every class turns a schedule into a scoreboard and makes quiet classes look dead. Showing it only when it is low, two places left rather than nine, gives people a reason to book now without advertising your empty slots. Show the capacity itself as well, because a room of six and a room of thirty are different products, and people like to know which one they are walking into.
PostgreSQL documentation, transaction isolation, read 14 October 2026
Pick yours
What sets the ceiling, and where to keep it
The limit is rarely a number you invent. It comes from a room, a rota or a box of kit. Pick the closest thing to what you run.
What sets the limit: The number of machines or mats that work that day
What you store: Capacity on each dated session, not on the class template
Waitlist worth building: Yes, people cancel and the place is worth money
Taking the money: Attended in the room, so card or Apple Pay, not in-app purchase
The payment line is the usual reading of the guidelines quoted below, not a ruling. The test is where the thing is consumed.
What happens when two people book the last place at the same moment?
Both bookings succeed, unless you did something specific to stop them. This is worth saying plainly, because the usual assumption is that a database would not let it happen. PostgreSQL documents Read Committed as its default isolation level, and at that level a SELECT without a FOR UPDATE or FOR SHARE clause sees only data committed before the query began. In effect, the documentation says, the query sees a snapshot of the database as of the instant it begins to run. Two requests twenty milliseconds apart each read eleven of twelve places taken, each decides there is room, and each inserts. Nothing malfunctioned. The database did exactly what it publishes.
The cheapest reliable fix is to make one row the referee and change it conditionally. Keep the count on the session row and write a single update that increments it only while it is below capacity. The documentation explains why this holds: UPDATE, DELETE, SELECT FOR UPDATE and SELECT FOR SHARE find only target rows committed as of the command start time, and when the row has already been locked by another transaction, the second writer waits for that transaction to commit or roll back and then re-evaluates its WHERE clause against the updated version of the row. The loser finds the count is now twelve, changes nothing, and gets zero rows affected back. That zero is your answer: the class is full, say so.
One trap inside that mechanism catches people who have read half of it. Locking the booking rows does nothing, because those commands only find rows that were already committed when the command started, and the competing booking is a brand new row that was not there to lock. Lock the one row that holds the number, or let a unique index on the session and the place catch the duplicate. The level above is Serializable, which the documentation describes as emulating serial transaction execution for all committed transactions, as if they had been executed one after another rather than concurrently. The price is written into the same page: applications using that level must be prepared to retry transactions due to serialization failures, which always return an SQLSTATE value of 40001, and even then you can see unique constraint violations from overlapping transactions after explicitly checking that the key is not present.
Two decisions follow from this, and both bite in production. First, the order of the place and the payment. Hold the place, then take the money, then confirm, and release the place if the payment fails, or take the money first and refund the people who lost the race. Holding first is kinder and needs a short expiry so abandoned checkouts give the place back. Second, test it with real concurrency. Set capacity to one, fire twenty requests at that session at once, and count what landed. One booking and nineteen clean refusals is the pass. You will never see the bug by tapping the button yourself.
PostgreSQL documentation, transaction isolation, read 14 October 2026
Does charging for a class have to go through in-app purchase?
For a class someone turns up to, no, and reaching for in-app purchase is the mistake that gets the app sent back. App Store Review Guideline 3.1.3(e) covers goods and services outside of the app, and says that if your app enables people to purchase physical goods or services that will be consumed outside of the app, you must use purchase methods other than in-app purchase to collect those payments, such as Apple Pay or traditional credit card entry. A place in a Tuesday evening class is consumed in a room, so it is card entry or Apple Pay, through whichever payment service you already use.
The same guidelines point the other way as soon as the class is watched in the app. Guideline 3.1.1 says that if you want to unlock features or functionality within your app, by way of example subscriptions, in-game currencies, game levels, access to premium content or unlocking a full version, you must use in-app purchase, and that apps may not use their own mechanisms to unlock content or functionality. A live streamed class or a library of recordings sits on that side of the line. The test is not what you call the product. It is where the thing is consumed. Both quotations are from the guidelines as published on 14 October 2026, and they are worth reading in full before you choose, because the wording moves.
The case that needs a decision is the mixed one. A pack of ten credits that can be spent on a mat in the studio or on a video at home is two products wearing one name. Splitting them is duller and safer: credits for attendance bought with a card, in-app content bought as in-app purchases. Capacity ties into this more than it looks, because a limited class is the one thing you can charge more for, and the shape of that decision belongs with your other app monetisation choices rather than being invented at the checkout screen.
What should the app do once the class is full?
Offer the waitlist in the same tap, and make the rules explicit. A waitlist needs four things: a position, a promotion rule, a window for the promoted person to confirm, and a clear line about money. If you are going to charge the card the moment a place opens, say so on the screen where they join. If you are not, say that too, because somebody will assume the opposite. Promote in order unless you have a reason not to, and write that reason down, because members notice when a place skips them.
The clock is the part that decides whether any of this earns money. A place that frees up ninety minutes before a class is worth something only if the next person finds out in time, so the promotion and the message are one job. That makes this a push notifications problem as much as a booking one, with a fallback to email or text for the people who turned notifications off. Give the confirm window an end: if nobody takes it within fifteen or thirty minutes, move down the list and tell the person why they lost it.
Two more things belong in the same code path. Cancelling has to return the place by changing the same counter in the same transaction that cancels, otherwise your limit drifts every busy week and nobody can tell you why. And resist deliberate overbooking. Airlines can do it because they have decades of no-show data and a desk to rebook you from. A studio with twelve machines has neither, and turning away a member who booked correctly costs more than the empty mat you were trying to avoid. If you really do overbook, decide in advance who gets turned away and what they get for it.
Four ways to stop the thirteenth booking
| Approach | What it guarantees | What it costs you | Start here |
|---|---|---|---|
| Count the bookings, then insert | Nothing. Both requests read the same number and both insert | One query, and an overbooked room every few weeks | No |
| One conditional update on the session row | The counter never passes capacity, because one row decides | A short wait for the second booker, and zero rows means full | Yes |
| One row per place, with a unique index | The loser gets a uniqueness error instead of a place | More rows to write, and an error path you have to handle | Yes |
| Serializable isolation on the booking transaction | Committed transactions behave as if they ran one at a time | Retry logic on every write path that can fail with 40001 | No |
Where this lands if a builder generated your app
Newly is an AI app builder. You describe the app in plain English and it writes a real React Native and Expo project that you own, so the booking write is code you can read and change rather than a setting in somebody else's product. It uploads iOS builds to TestFlight, publishes to Google Play internal testing, builds a standalone Android APK, and keeps a two way GitHub sync, so the project is never trapped inside the tool that wrote it. The price is $25 a month, and there is no free tier.
Ask for class bookings and you get a schedule, a capacity field and a booking flow. The thing to ask for by name is the refusal: one conditional write that cannot pass the limit, a full state in the interface, and a test that fires concurrent bookings at the same session. Say it in those words. A generated booking screen that counts and then inserts looks completely correct in a demo, which is why this bug ships so often. The same applies to the payment route. Tell it the class is attended in person, so the charge goes through a card, not through in-app purchase.
One limit worth knowing before you plan a launch. Newly does not submit to the App Store for you, because Apple requires the content owner to do it. Guideline 4.2.6 says apps created from a commercialized template or app generation service will be rejected unless they are submitted directly by the provider of the app's content, and that these services should not submit apps on behalf of their clients. So the build arrives in TestFlight and you press submit under your own developer account. For a studio that is the right way round anyway, since the refund policy and the no-show rules behind your capacity limit are yours to stand behind.
Apple, App Store Review Guidelines, guideline 4.2.6, read 14 October 2026
Questions people ask about class capacity
Store a capacity number on each dated session rather than on the class template, keep a count of confirmed bookings beside it, and make the booking itself a single conditional write that only succeeds while the count is below capacity. Treat zero rows changed as the class being full and show that instead of an error. Everything else, the waitlist, the staff hold, the equipment that is out of service, hangs off those two numbers. If a booking taken at the desk does not write to the same table, the limit in the app is a guess.
Describe the class, then describe the refusal
Capacity, the waitlist and the cancellation window are three sentences in a prompt. The fourth sentence is the one that saves you: the booking must be a single write that cannot pass the limit, and the app has to show the class as full when it refuses.
Start building