Designing a Medical Appointment Booking System: Time Slots and Concurrency Control
1. Overview
Booking a medical appointment sounds simple: the patient picks a doctor, picks a time, then clicks “Book”. The hard part lies elsewhere. Hundreds of people can click the same slot at 7:00 AM when the clinic opens slots. The system must ensure no two people get the same spot.
This article covers the complete design: requirements, data model, API, booking and cancellation flows. Then it focuses on the most important part: preventing double bookings and handling burst load. After reading, you can build the core booking logic yourself using Spring Boot and PostgreSQL. This core remains correct even under heavy concurrent traffic.
2. Requirements
2.1. Functional Requirements
- Doctors (or admins) create working schedules. The system generates fixed slots, for example 15 minutes per slot.
- Each slot has a capacity. A specialist consultation is usually 1. Vaccination might be 10 people per time window.
- Patients view available slots by doctor and date, then book or cancel.
- A patient cannot hold two valid appointments in the same slot.
- After successful booking, the system sends SMS or email confirmation.
2.2. Scale and Constraints
- 2,000 doctors. Each doctor has about 40 slots per day. Schedules open 14 days in advance, meaning roughly 1.1 million active slots.
- Read traffic far exceeds write traffic, ratio about 50:1.
- Burst load: a popular doctor’s schedule opens at 7:00. The first 30 seconds might see 5,000 requests/second hitting a few dozen slots.
- Strong consistency for booking operations. Never exceed capacity. The list of empty slots can be a few seconds stale.
- Clients may retry on network issues. Therefore, booking operations must be idempotent.
3. Data Model
A key decision: create slots in advance as separate rows instead of computing “available hours” on the fly. When each slot is a row, you have a concrete point to lock and check capacity. Conflicts are resolved at the row level.
CREATE TABLE time_slot (
id BIGSERIAL PRIMARY KEY,
doctor_id BIGINT NOT NULL,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
capacity INT NOT NULL CHECK (capacity > 0),
booked_count INT NOT NULL DEFAULT 0,
CONSTRAINT chk_booked CHECK (booked_count BETWEEN 0 AND capacity),
CONSTRAINT uq_doctor_start UNIQUE (doctor_id, start_at)
);
CREATE TABLE appointment (
id BIGSERIAL PRIMARY KEY,
slot_id BIGINT NOT NULL REFERENCES time_slot(id),
patient_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- BOOKED, CANCELLED
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_active_patient_slot
ON appointment (slot_id, patient_id)
WHERE status = 'BOOKED';
Each constraint in the schema plays its own role:
chk_bookedis the final safeguard. If code has a bug, the database still rejectsbooked_countexceedingcapacity.uq_doctor_startprevents duplicate slots when the schedule-creation job reruns.uq_active_patient_slotis a PostgreSQL partial unique index. A patient can have at most oneBOOKEDappointment in each slot. Cancelled appointments don’t count, so the patient can rebook.idempotency_key UNIQUEprevents client retries from creating a second appointment.
The JPA entity for time_slot (getters omitted):
@Entity
@Table(name = "time_slot")
public class TimeSlot {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long doctorId;
private Instant startAt;
private Instant endAt;
private int capacity;
private int bookedCount;
protected TimeSlot() {}
public TimeSlot(Long doctorId, Instant startAt, int capacity) {
this.doctorId = doctorId;
this.startAt = startAt;
this.endAt = startAt.plus(15, ChronoUnit.MINUTES);
this.capacity = capacity;
}
// getters...
}
4. API Design
GET /api/doctors/{doctorId}/slots?date=2026-10-12 -> list of available slots
POST /api/appointments -> book an appointment
Header: Idempotency-Key: 6f1c...
Body: { "slotId": 1001, "patientId": 77 }
DELETE /api/appointments/{id} -> cancel appointment
Response codes for POST /api/appointments:
201 Created -> booking successful
200 OK -> retry with same Idempotency-Key, return existing appointment
409 Conflict -> slot is full or patient already has an appointment in this slot
429 Too Many Requests -> rate limit exceeded
The client generates Idempotency-Key (UUID) for each button click. On retry, the client sends the same key.
5. Preventing Double Bookings
5.1. Why “Check Then Write” Is Wrong
The first approach many developers think of:
TimeSlot slot = slotRepository.findById(slotId).orElseThrow();
if (slot.getBookedCount() < slot.getCapacity()) { // (1) read
slot.setBookedCount(slot.getBookedCount() + 1); // (2) write
appointmentRepository.save(appointment);
}
Suppose threads A and B both read at step (1) and see booked_count = 0, capacity = 1. Both pass the condition and both write. Two appointments end up for the same spot. At PostgreSQL’s default READ COMMITTED isolation level, this is a lost update race.
5.2. Conditional Update: Check and Write in One Statement
The fix is to merge the check and write into a single UPDATE statement with a WHERE clause. The database locks the row during the update. The second thread waits, then re-evaluates the WHERE condition against the new value.
public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
@Modifying
@Query("""
update TimeSlot s
set s.bookedCount = s.bookedCount + 1
where s.id = :id
and s.bookedCount < s.capacity
and s.startAt > :now
""")
int tryReserve(@Param("id") long id, @Param("now") Instant now);
@Modifying
@Query("update TimeSlot s set s.bookedCount = s.bookedCount - 1 where s.id = :id and s.bookedCount > 0")
int release(@Param("id") long id);
}
tryReserve returns the number of rows affected. If the result is 1, you got the slot. If it’s 0, the slot is full, time passed, or doesn’t exist. No need for SELECT ... FOR UPDATE or optimistic locking with a version column. The lock is held only briefly.
The booking service:
@Service
public class BookingService {
private final TimeSlotRepository slotRepository;
private final AppointmentRepository appointmentRepository;
private final Clock clock;
public BookingService(TimeSlotRepository slotRepository,
AppointmentRepository appointmentRepository,
Clock clock) {
this.slotRepository = slotRepository;
this.appointmentRepository = appointmentRepository;
this.clock = clock;
}
@Transactional
public Appointment book(long slotId, long patientId, String idempotencyKey) {
var existing = appointmentRepository.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
return existing.get(); // retry: return old result
}
if (slotRepository.tryReserve(slotId, clock.instant()) == 0) {
throw new SlotFullException(slotId);
}
// saveAndFlush to make constraint violations fail immediately in this transaction
return appointmentRepository.saveAndFlush(
Appointment.booked(slotId, patientId, idempotencyKey));
}
}
Clock should be a bean, e.g. Clock.systemUTC(), so tests can inject a fixed time. The code works as follows:
- The idempotency check runs first. If the client retries after the first request committed, the client gets back the exact same appointment.
- If
tryReservesucceeds butsaveAndFlushviolatesuq_active_patient_slot(patient already has an appointment), Spring throwsDataIntegrityViolationException. The transaction rolls back entirely, including the increment tobooked_count. The spot is automatically returned. - Two retry requests can arrive nearly simultaneously and both miss the key. Then one will violate
idempotency_key UNIQUE. In the controller, catch this error, fetch the appointment by key, and return200.
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> onConflict(DataIntegrityViolationException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"You already have an appointment in this time slot"));
}
Note: The example above returns
409for any constraint violation to keep code short. In a real system, you should distinguish by constraint name. Violatinguq_active_patient_slotreturns409. Violating idempotency_key uniqueness fetches the appointment by key and returns200.
5.3. Cancellation Must Also Be Conditional
Cancellation also races. If a patient clicks “Cancel” twice, booked_count can be decremented twice. The fix is the same: only decrement when the state transition actually happens.
@Transactional
public void cancel(long appointmentId) {
Appointment appt = appointmentRepository.findById(appointmentId).orElseThrow();
int changed = appointmentRepository.markCancelled(appointmentId); // where status = 'BOOKED'
if (changed == 1) {
slotRepository.release(appt.getSlotId());
}
}
markCancelled is a @Modifying query: update Appointment a set a.status = 'CANCELLED' where a.id = :id and a.status = 'BOOKED'. A second cancel request gets changed = 0 and does nothing extra.
5.4. Testing Under Concurrent Load
The test below puts 20 patients competing for one slot with capacity 1. You should run it with a real PostgreSQL via Testcontainers, since H2 doesn’t support partial indexes.
@SpringBootTest
class BookingServiceConcurrencyTest {
@Autowired BookingService bookingService;
@Autowired TimeSlotRepository slotRepository;
@Test
void onlyOnePatientGetsSingleCapacitySlot() throws Exception {
TimeSlot slot = slotRepository.save(
new TimeSlot(42L, Instant.now().plus(1, ChronoUnit.DAYS), 1));
int patients = 20;
var start = new CountDownLatch(1);
var success = new AtomicInteger();
try (ExecutorService pool = Executors.newFixedThreadPool(patients)) {
for (int i = 0; i < patients; i++) {
long patientId = i;
pool.submit(() -> {
start.await();
try {
bookingService.book(slot.getId(), patientId, "key-" + patientId);
success.incrementAndGet();
} catch (SlotFullException ignored) {
}
return null;
});
}
start.countDown(); // release all 20 threads at once
} // close() waits for all tasks to finish
assertThat(success.get()).isEqualTo(1);
assertThat(slotRepository.findById(slot.getId()).orElseThrow().getBookedCount())
.isEqualTo(1);
}
}
CountDownLatch holds all threads until they start together, making race conditions more likely. Expected outcome:
success = 1
booked_count = 1
19 other threads get SlotFullException
If you replace tryReserve with the “read then write” approach from section 5.1, this test usually fails with success greater than 1.
6. Handling Burst Load at Slot Release Time
Conditional update ensures correctness, but not yet performance. 5,000 requests/second all hitting a few rows will create lock queues in the database. The connection pool drains and other APIs slow down. There are three layers of defense.
6.1. Caching Empty Slot Lists
Most traffic is GET slots. You can cache the result by doctorId + date in Redis with a short TTL, say 3–5 seconds. When booking or cancellation succeeds, delete the corresponding key. The list can be slightly stale, but that’s okay: the final decision still comes from tryReserve on the database.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
6.2. Rate Limiting and Early Slot Rejection
@Component
public class BookingGate {
private final StringRedisTemplate redis;
public BookingGate(StringRedisTemplate redis) { this.redis = redis; }
public boolean allowPatient(long patientId) {
String key = "rl:booking:" + patientId;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofSeconds(10));
}
return count != null && count <= 5; // max 5 booking attempts per 10 seconds
}
public boolean isKnownFull(long slotId) {
return Boolean.TRUE.equals(redis.hasKey("slot:full:" + slotId));
}
public void markFull(long slotId) {
redis.opsForValue().set("slot:full:" + slotId, "1", Duration.ofSeconds(30));
}
}
The controller calls allowPatient first. If it returns false, the controller returns 429. When tryReserve returns 0, call markFull. Subsequent requests for the same slot are rejected at Redis without touching the database. The 30-second TTL lets the slot “reopen” if someone cancels.
Note: Don’t use Redis as the source of truth for capacity, e.g.,
DECRa counter and assume booking is done. When Redis and database diverge (Redis restart, transaction rollback), you’ll oversell or lose spots. Redis should only be a pre-filter before the database.
6.3. Asynchronous Notifications via Outbox
Don’t call the SMS service inside the booking transaction. Instead, write an extra row to an outbox_event table in the same transaction. A worker (or Debezium) will push the AppointmentBooked event to Kafka, and the notification service consumes it. The booking transaction thus stays short and doesn’t depend on external services. You also never send SMS for an appointment that rolled back.
7. When to Extend the Design
- Holding a spot during payment: add
HELDstatus withexpires_atcolumn.tryReservestill incrementsbooked_count. A periodic job moves expiredHELDappointments toEXPIREDand callsrelease. Never let a held spot languish forever. - Preventing a patient from booking two doctors at the same time: a unique index on
slot_idisn’t enough. Store atstzrange(start_at, end_at)inappointment, then use PostgreSQL’s exclusion constraint (EXCLUDE USING gist, requiresbtree_gistextension) on the pair(patient_id, time_range). - Flexible-duration slots (doctor works 15, 30, or 45 minute sessions): the fixed-slot model no longer fits. You need an exclusion constraint on time range per doctor instead of counting capacity.
- Don’t use
SERIALIZABLEisolation for the whole system just to prevent conflicts. It’s correct but causes many serialization failures under high load. Conditional update plus constraints solve this problem at lower cost. - Don’t use distributed locks (Redisson, Zookeeper) when you only need to lock one row. The database already has locks; each additional component is another point of failure.
8. Conclusion
Designing a medical appointment booking system revolves around one principle: push conflict detection down to the layer with strong consistency, which is the database. Specifically:
- Model each slot as a row with
capacityandbooked_count. - Use conditional update to check and write in one atomic step.
- Use
CHECKconstraints and partial unique indexes as final safeguards. - Use
Idempotency-Keyfor safe retries.
The layers above it (Redis caching, rate limiting, marking slots full, outbox with Kafka) help the system survive burst load at slot release. These layers don’t affect the correctness of booking. Finally, always have a multi-threaded test like section 5.4 running on a real database. That’s the most reliable way to prove your system never gives two people the same spot.