// Prisma schema excerpt (TypeScript/PostgreSQL)
model Service {
id String @id @default(cuid())
providerId String
name String
durationMin Int
priceCents Int
bufferMin Int @default(0)
}
model StaffMember {
id String @id @default(cuid())
providerId String
name String
startHour Int @default(9)
endHour Int @default(18)
maxDailyCap Int @default(10)
}
model Appointment {
id String @id @default(cuid())
providerId String
staffId String
serviceId String
clientName String
bookedAt DateTime @default(now())
slotStart DateTime
slotEnd DateTime
status String @default("PENDING_PAYMENT")
paymentRef String?
@@index([providerId, slotStart, slotEnd])
@@index([staffId, slotStart])
}
Step 2: Implement Overlap Detection at the Query Boundary
Application-level validation fails under concurrent requests. The database must reject conflicting allocations atomically. We use explicit temporal range comparisons rather than relying on ORM abstractions.
async function findConflictingAppointments(
prisma: PrismaClient,
providerId: string,
staffId: string,
proposedStart: Date,
proposedEnd: Date
): Promise<Appointment[]> {
return prisma.appointment.findMany({
where: {
providerId,
staffId,
status: { notIn: ["CANCELLED", "NO_SHOW"] },
OR: [
{ slotStart: { lte: proposedEnd }, slotEnd: { gte: proposedStart } },
{ slotStart: { gte: proposedStart }, slotStart: { lte: proposedEnd } },
{ slotEnd: { gte: proposedStart }, slotEnd: { lte: proposedEnd } }
]
}
});
}
Step 3: Wrap Reservation in a Serializable Transaction
Concurrency requires explicit isolation. PostgreSQL's SERIALIZABLE isolation level or advisory locks prevent double-booking when multiple clients request the same slot simultaneously.
async function reserveSlot(
prisma: PrismaClient,
payload: ReserveSlotInput
): Promise<ReservationResult> {
return prisma.$transaction(async (tx) => {
const service = await tx.service.findUniqueOrThrow({
where: { id: payload.serviceId }
});
const proposedStart = new Date(payload.requestedTime);
const proposedEnd = new Date(proposedStart.getTime() + service.durationMin * 60000);
const conflicts = await findConflictingAppointments(
tx,
payload.providerId,
payload.staffId,
proposedStart,
proposedEnd
);
if (conflicts.length > 0) {
throw new SchedulingConflictError("Requested slot overlaps with existing reservation");
}
const dailyCount = await tx.appointment.count({
where: {
staffId: payload.staffId,
slotStart: { gte: new Date(proposedStart.toDateString()), lt: new Date(proposedStart.toDateString()) },
status: { notIn: ["CANCELLED"] }
}
});
const staff = await tx.staffMember.findUniqueOrThrow({ where: { id: payload.staffId } });
if (dailyCount >= staff.maxDailyCap) {
throw new CapacityExceededError("Staff member has reached daily booking limit");
}
return tx.appointment.create({
data: {
providerId: payload.providerId,
staffId: payload.staffId,
serviceId: payload.serviceId,
clientName: payload.clientName,
slotStart: proposedStart,
slotEnd: proposedEnd,
status: "PENDING_PAYMENT",
paymentRef: payload.paymentRef
}
});
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
Architecture Decisions and Rationale
- Database-boundary validation: Overlap detection runs inside the transaction. This eliminates race conditions where two requests pass application-level checks before either commits.
- Explicit temporal comparisons: Using
lte/gte ranges avoids ORM-specific date manipulation quirks and works consistently across PostgreSQL, MySQL, and SQLite.
- State-aware filtering: Conflicts only count active or pending appointments. Cancelled and no-show records are excluded to prevent false positives.
- Capacity gating: Daily limits are evaluated within the same transaction to prevent overbooking when multiple services are booked simultaneously.
- Serializable isolation: PostgreSQL guarantees that concurrent transactions see a consistent snapshot. If a conflict emerges during execution, the database aborts one transaction, forcing a retry.
Pitfall Guide
1. Ignoring Timezone and DST Boundaries
Explanation: Storing times as local strings or naive Date objects causes silent shifts during daylight saving transitions or cross-region bookings.
Fix: Normalize all inputs to UTC at the API boundary. Store slotStart and slotEnd as UTC timestamps. Convert to local time only at the presentation layer using Intl.DateTimeFormat or date-fns-tz.
2. Race Conditions on Concurrent Requests
Explanation: Two clients request the same slot milliseconds apart. Application-level validation passes for both, and both insert successfully.
Fix: Enforce overlap detection inside a database transaction with SERIALIZABLE isolation or use advisory locks (pg_advisory_xact_lock) scoped to staffId and date.
3. Hardcoding Service Buffers
Explanation: Cleaning time, client check-in, and setup vary by service type and staff experience. Hardcoded buffers break capacity calculations.
Fix: Store bufferMin per service. When calculating proposedEnd, add service.durationMin + service.bufferMin. Validate buffers against staff operating windows.
4. Treating Staff as Interchangeable Resources
Explanation: Assuming all staff members can perform all services ignores skill matrices, certification requirements, and shift patterns.
Fix: Introduce a StaffServiceMapping table. Validate staffId against allowed services before overlap detection. Filter availability by active shift windows.
5. Missing Payment Verification Delays
Explanation: In markets using bank transfers or screenshot-based proof, payment confirmation lags behind booking creation. Unpaid reservations block capacity indefinitely.
Fix: Implement a state machine (PENDING_PAYMENT β CONFIRMED β COMPLETED/CANCELLED). Run a cron job or webhook listener that releases unpaid slots after a configurable timeout (e.g., 15 minutes).
6. Overlooking Cancellation and No-Show Logic
Explanation: Deleting cancelled records destroys audit trails and breaks capacity analytics. Ignoring no-shows inflates utilization metrics.
Fix: Use soft deletes or status transitions. Maintain a separate BookingAuditLog table for compliance. Trigger capacity recalculation on status changes.
7. Failing to Handle Multi-Tenant Data Leakage
Explanation: Forgetting to scope queries by providerId allows one business to see or book another's slots.
Fix: Enforce providerId as a mandatory filter in every query. Use Prisma middleware or database row-level security (RLS) to guarantee tenant isolation.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Low concurrency (<50 bookings/day) | Application-level validation + optimistic locking | Simpler implementation, lower DB load | Low infrastructure cost |
| High concurrency (>200 bookings/day) | Database transaction with SERIALIZABLE isolation | Prevents race conditions, guarantees consistency | Moderate DB CPU overhead |
| Multi-region / cross-timezone clients | UTC storage + presentation-layer conversion | Eliminates DST shifts, simplifies overlap math | Slight frontend complexity |
| Payment verification delays | State machine with timeout release | Prevents capacity hoarding, improves turnover | Requires cron/webhook infrastructure |
| Strict compliance / audit requirements | Soft deletes + audit log table | Preserves history, supports dispute resolution | Additional storage & query complexity |
Configuration Template
// scheduling-engine.ts
import { PrismaClient, TransactionIsolationLevel } from '@prisma/client';
const prisma = new PrismaClient();
export class SchedulingEngine {
async reserve(
providerId: string,
staffId: string,
serviceId: string,
clientName: string,
requestedTime: string,
paymentRef?: string
) {
return prisma.$transaction(async (tx) => {
const service = await tx.service.findUniqueOrThrow({ where: { id: serviceId } });
const start = new Date(requestedTime);
const end = new Date(start.getTime() + (service.durationMin + service.bufferMin) * 60000);
const conflicts = await tx.appointment.findMany({
where: {
providerId,
staffId,
status: { notIn: ['CANCELLED', 'NO_SHOW'] },
OR: [
{ slotStart: { lte: end }, slotEnd: { gte: start } },
{ slotStart: { gte: start }, slotStart: { lte: end } },
{ slotEnd: { gte: start }, slotEnd: { lte: end } }
]
}
});
if (conflicts.length > 0) {
throw new Error('SCHEDULING_CONFLICT');
}
return tx.appointment.create({
data: {
providerId,
staffId,
serviceId,
clientName,
slotStart: start,
slotEnd: end,
status: 'PENDING_PAYMENT',
paymentRef: paymentRef ?? null
}
});
}, { isolationLevel: TransactionIsolationLevel.Serializable });
}
async releaseUnpaidSlots() {
const cutoff = new Date(Date.now() - 15 * 60 * 1000);
await prisma.appointment.updateMany({
where: {
status: 'PENDING_PAYMENT',
bookedAt: { lte: cutoff }
},
data: { status: 'CANCELLED_TIMEOUT' }
});
}
}
Quick Start Guide
- Initialize the schema: Run
npx prisma init and paste the temporal schema into schema.prisma. Execute npx prisma db push to create tables with proper indexes.
- Configure isolation: Set
isolationLevel: Prisma.TransactionIsolationLevel.Serializable in your transaction wrapper. Verify PostgreSQL supports it (default in v12+).
- Deploy the engine: Import
SchedulingEngine into your Next.js API route or server action. Pass UTC-normalized timestamps from the client.
- Add timeout cleanup: Schedule
releaseUnpaidSlots() via a cron job (e.g., Vercel Cron, GitHub Actions, or node-cron) to run every 5 minutes.
- Validate with concurrency tests: Use a load testing tool to send 10 simultaneous requests for the same slot. Confirm only one succeeds and the rest return
SCHEDULING_CONFLICT.
Building a booking system requires abandoning the CRUD mindset. Time demands explicit constraints, transactional guarantees, and state-aware capacity management. When overlap detection moves from application logic to the database boundary, the system stops recording collisions and starts enforcing availability. The result is a scheduling engine that scales predictably, survives concurrent load, and aligns with the operational realities of service-based businesses.