Patient-Tracker: System Architecture, Database Design & Concurrency Deep Dive
Overview: Comprehensive technical breakdown of the
patient-trackerplatform. This document explores the end-to-end architecture, the complete Prisma/PostgreSQL schema design, real-time doctor-patient communication with Socket.IO, the PostgreSQL partial unique index concurrency guard, and an analysis of alternative concurrency control patterns.
Table of Contents
- End-to-End System Architecture
- Database Design & Schema Breakdown (Prisma & PostgreSQL)
- Real-Time Doctor-Patient Communication with Socket.IO
- Concurrency Control: Solving Slot Double-Booking
- Alternative Concurrency Solutions & Trade-off Matrix
- Solution 1: Pessimistic Row-Level Locking (
FOR UPDATE) - Solution 2: Optimistic Locking with Versioning (CAS)
- Solution 3: Distributed Locks via Redis (Redlock /
SET NX) - Solution 4: Serializable Transaction Isolation
- Solution 5: Single-Threaded Job Queue / Actor Model
- Comprehensive Trade-off Comparison Matrix
- Solution 1: Pessimistic Row-Level Locking (
1. End-to-End System Architecture
High-Level Architecture Diagram
+========================================================================================+
| CLIENT TIER (Single Page Applications) |
| |
| +------------------------------------+ +------------------------------------+ |
| | Patient Portal (React / Vite) | | Doctor Portal (React / Vite) | |
| +------------------------------------+ +------------------------------------+ |
+====================================+===============================+===================+
| |
REST API (Axios + JWT) | | WebSocket / Engine.IO
v v
+========================================================================================+
| TRANSPORT, SECURITY & REAL-TIME TIER |
| |
| +------------------------------------+ +------------------------------------+ |
| | CORS & Express JSON Body Parsers | | Socket.IO Server Engine | |
| +-----------------+------------------+ | - Chat Rooms: join-chat, leave | |
| | | - Real-Time: send/receive-message | |
| v | - Push: medications-updated | |
| +------------------------------------+ +-----------------+------------------+ |
| | JWT Auth Middleware (RBAC Guards) | | |
| +-----------------+------------------+ | |
+=====================|===========================================|======================+
| |
v |
+=================================================================|======================+
| APPLICATION CONTROLLER TIER | |
| | |
| +----------------+----------------+----------------+ | |
| | /auth | /doctor | /patient | | |
| | Auth & JWT | Profiles/Slots| Records/Adhere| | |
| +----------------+----------------+----------------+ | |
| | /appointments | /chats | /api/medicines| | |
| | Booking Engine| Message Store | Drug Catalog | | |
| +----------------+----------------+----------------+ | |
| | | |
| +---------------------+---------------------+ |
| | |
| v |
| +-------------------------------------+ |
| | Prisma Client (ORM & DAL) | |
| +------------------+------------------+ |
+===========================================|============================================+
|
v
+========================================================================================+
| PERSISTENCE & DATA TIER |
| |
| +-------------------------------------+ |
| | PostgreSQL Relational DBMS | |
| | | |
| | [Concurrency Guard Engine] | |
| | Partial Unique B+ Tree Index: | |
| | ON "Appointment"(doctorId,slotStart| |
| | WHERE status <> 'cancelled'; | |
| +-------------------------------------+ |
+========================================================================================+Tier Breakdown
-
Frontend Tier (React 18 + Vite + Tailwind CSS):
- Single Page Application (SPA) architecture offering responsive views for both Patients and Doctors.
- Centralized API communication via Axios instances configured with automatic JWT authorization header injection.
- Real-time client via
socket.io-clientmaintaining a persistent full-duplex socket for live chat rooms and medication notifications.
-
Application Tier (Node.js + Express):
- Single-threaded, event-loop driven runtime (
libuv) optimized for high I/O concurrency. - Dual-server architecture mounted on the same native Node HTTP server:
- Express Application: Handles stateless request-response REST APIs.
- Socket.IO Server: Manages stateful, persistent TCP/WebSocket connections.
- Middleware pipeline for authentication (
authenticate), CORS, and background scheduling triggers (triggerMedicationReminder,checkForMissedMedications).
- Single-threaded, event-loop driven runtime (
-
Persistence Tier (PostgreSQL + Prisma ORM):
- Relational DBMS providing strict ACID guarantees.
- Prisma acts as the type-safe Data Access Layer (DAL), generating TypeScript/JavaScript query models, managing database connections, and running automated schema migrations.
Authentication & Authorization Model
- Stateless Tokens: Authentication is handled using JSON Web Tokens (JWT). When a user logs in via
/auth/login, the server issues a signed JWT containingid,email, androle("patient"or"doctor"). - Role-Based Access Control (RBAC): The
authenticatemiddleware decodes the token fromAuthorization: Bearer <token>, attachingreq.userto the request context. Route handlers enforce strict role guards (e.g., only patients can invokecreateAppointment, and only doctors can create prescriptions).
2. Database Design & Schema Breakdown (Prisma & PostgreSQL)
Entity Relationship Diagram (ERD)
+--------------------------------------------------------------------------------------------------+
| ENTITY RELATIONSHIP DIAGRAM |
+--------------------------------------------------------------------------------------------------+
+--------------------------+ +--------------------------+
| PATIENT | | DOCTOR |
+--------------------------+ +--------------------------+
| * id : Int (PK, AI)|<----+ +---->| * id : UUID (PK) |
| name : String | | | | name : String |
| email : String (UQ) | | M:N | | email : String (UQ) |
| password : String? | +======+ | specializ: String |
| age : Int? | DoctorToPatient| password : String |
| gender : String? | +-------------+------------+
| status : String | |
+-------------+------------+ |
| |
+---------+---------+---------+---------+ |
| 1:N | 1:N | 1:N | 1:N | 1:N |
v v v v v |
+-------+ +-------+ +-------+ +-------+ +-------+ |
|Medical| |Prescr-| |Appoint| |Medici-| |Health | |
|Record | |iption | |ment | |neAdher| |Metrics| |
+-------+ +---+---+ +---+---+ +-------+ +-------+ |
| | | 1:N | 1:N |
| | +-----------+---------------+
| | |
| +-------------------+--- (Doctor 1:N Appointment)
| |
+-----------------------------+--- (Doctor 1:N Prescription)
|
v 1:N
+------------------+
|PrescribedMedicine|
+------------------+
| id : UUID |
| prescripId: FK |
| medicineId: Str |
| timing : Json |
+------------------+
+-----------------------+ +-----------------------+
| CHAT | 1:N | MESSAGE |
+-----------------------+ +-----------------------+
| * id : UUID +------------->| * id : UUID |
| patientId : Int (FK)| | chatId : UUID |
| doctorId : Str (FK)| | senderId : String |
| createdAt : DateTime| | senderType : String |
| updatedAt : DateTime| | content : String |
+-----------------------+ | readStatus : Boolean|
+-----------------------+Model-by-Model Analysis: What, How & Why
1. Patient
- What it is: The central user entity for individuals receiving healthcare services.
- How it is used: Stores core demographic data, authentication credentials, and acts as the parent anchor for all clinical records, appointments, and telemetry.
- Why it is used: Isolates patient identity and privacy. Uses an auto-incrementing integer
Int @id @default(autoincrement())for compact indexing and fast joins across millions of health records.
2. Doctor
- What it is: Represents licensed medical practitioners on the platform.
- How it is used: Contains credentials, specialization, and relationships to assigned patients, issued prescriptions, and booked appointments.
- Why it is used: Uses UUID primary keys (
String @id @default(uuid())) to prevent ID enumeration (preventing scrapers from guessing sequential doctor IDs). Maintains an explicit M:N relation withPatient(@relation("DoctorToPatient")) allowing doctors to access medical histories only for their assigned patients.
3. Appointment
- What it is: The temporal booking record binding a
Patient, aDoctor, and a specific calendar time slot. - How it is used: Stores
appointmentDate,slotStart(normalized to exact hour boundaries),status("scheduled","completed","cancelled","missed"), andpurpose. - Why it is used: Serves as the critical scheduling engine. Employs a PostgreSQL Partial Unique Index on
(doctorId, slotStart)wherestatus <> 'cancelled'to provide hardware-level concurrency enforcement against double-booking.
4. Prescription & PrescribedMedicine (Parent-Child Pattern)
- What it is: A normalized master-detail relationship representing medical prescriptions.
- How it is used:
Prescriptionstores metadata: consultation date, condition/diagnosis, doctor ID, and patient ID.PrescribedMedicinestores individual line items: dosage, instructions, duration, and aJsoncolumn fortiming({ morning: true, afternoon: false, evening: true }).
- Why it is used:
- Normalizing medicines into a separate table allows querying which patients are taking a specific drug without regex parsing.
- Using PostgreSQL
Jsonfortimingprovides flexibility without requiring 3 extra boolean columns in the relational table.
5. Chat & Message
- What it is: The persistence backing for doctor-patient consultations.
- How it is used:
Chatdefines a conversation channel between one doctor and one patient.Messagestores individual messages withsenderId,senderType("doctor"or"patient"),content, andreadStatus.
- Why it is used: Decouples conversation threads from individual messages. Enables fast unread-badge calculation (
WHERE chatId = ? AND readStatus = false AND senderType != role) and rapid query pagination (ORDER BY createdAt ASC).
6. MedicineAdherence & HealthMetrics
- What it is: Longitudinal telemetry and medication compliance tracking.
- How it is used:
MedicineAdherencerecords daily intake state ("Taken","Missed","Pending"), scheduling windows ("morning"), and reminder flags (reminderSent).HealthMetricsrecords time-series values (e.g., Blood Pressure, Heart Rate, Glucose).
- Why it is used: Enables background cron workers to run daily queries, detect missed doses, update adherence scores, and dispatch automated real-time notifications.
ID Strategy: Auto-Increment vs. UUID
The schema applies a deliberate, mixed primary key strategy:
| Model | ID Strategy | Technical Rationale |
|---|---|---|
Patient | Int (Autoincrement) | Highly internal, frequently joined across all tables. 4-byte integers maximize B+ Tree branch fan-out in memory compared to 16-byte UUIDs. |
Doctor | String (UUID v4) | Public-facing identifiers exposed in URLs and client state. UUIDs eliminate enumeration attacks (e.g., competitor crawling doctor IDs sequentially). |
Appointment, Prescription, Chat, Message | String (UUID v4) | Distributed generation readiness. UUIDs can be generated by client or server instances without consulting a centralized database sequence or locking a table counter. |
3. Real-Time Doctor-Patient Communication with Socket.IO
What is Socket.IO? (Under the Hood)
Many developers assume Socket.IO is simply a wrapper around WebSockets. In reality, Socket.IO is a specialized real-time transport and messaging framework built on top of an underlying engine called Engine.IO.
Core Architectural Mechanics:
- Engine.IO Handshake & Transport Fallback:
When a client initiates a connection, Socket.IO does not open a WebSocket immediately. It begins with HTTP Long-Polling (
GET /socket.io/?EIO=4&transport=polling). Once the initial handshake succeeds, it probes whether the network/firewall supports the WebSocket protocol. If supported, it upgrades the connection via HTTP status code101 Switching Protocols. - Heartbeats & Liveness Monitoring:
Socket.IO continuously exchanges lightweight ping-pong packets (e.g., every 25 seconds). If a client drops offline without cleanly closing the TCP socket (e.g., phone enters airplane mode or loses Wi-Fi), the server automatically detects the timeout and fires the
disconnectevent. - Packet Framing & Buffering: Messages are framed with metadata headers (packet type, namespace, ack ID). If a connection drops temporarily, Socket.IO automatically buffers messages and attempts transparent reconnection.
- Rooms and Namespaces: Server-side logical partitioning. A "room" is an in-memory channel on the server. Sockets can join or leave rooms dynamically without requiring separate TCP connections.
Socket.IO vs. Raw WebSockets vs. HTTP Polling
| Dimension | HTTP Short/Long Polling | Raw WebSockets (ws) | Socket.IO |
|---|---|---|---|
| Protocol | Standard HTTP/1.1 or HTTP/2 | RFC 6455 TCP Protocol | Engine.IO (Polling $\to$ WebSocket Upgrade) |
| Latency | High (header overhead per poll) | Ultra-low (sub-millisecond frames) | Ultra-low (sub-millisecond frames) |
| Firewall / Proxy Compatibility | 100% (Standard HTTP ports) | Moderate (can be blocked by corporate proxies) | 100% (falls back gracefully to HTTP long-polling) |
| Reconnection & Heartbeats | Manual implementation required | Manual ping/pong & reconnection logic required | Built-in automated reconnection, jitter, & heartbeat detection |
| Channel / Room Multiplexing | Manual routing | Manual routing | Built-in (socket.join(room)) |
How Doctor-Patient Chat Works (Step-by-Step Flow)
+--------------+ +---------------+ +--------------+ +-------------+
|Patient Client| |Express API(DB)| |Socket.IO Srv | |Doctor Client|
+-------+------+ +-------+-------+ +------+-------+ +------+------+
| | | |
|=== Step 1: Handshake & Room Subscription ======================================|
| | |
|-- 1. Connect (Handshake HTTP -> WS) ----------------->| |
| |<-- 2. Connect (WS) ----|
|-- 3. emit("join-chat", chatId) ---------------------->| |
| [socket.join(chatId)] |<-- 4. emit("join-chat")|
| | [socket.join(chatId)]
| | |
|=== Step 2: Message Creation & Dual-Write ======================================|
| | |
|-- 5. POST /chats/:chatId/messages {content} --------->| |
| (with JWT Bearer Token) | | |
| |-- 6. Verify Auth & Chat| |
| | Membership | |
| |-- 7. prisma.message.create() |
| |-- 8. prisma.chat.update(now) |
|<- 9. HTTP 201 Created -------| | |
| (Confirmed in PostgreSQL) | |
| | |
|-- 10. emit("send-message", messageData) ------------->| |
| |-- 11. io.to(chatId). |
| | emit("receive- |
| | message", data) |
| |----------------------->|
| | (Instant Push Delivery)|
|<- 12. "receive-message" echo -------------------------| |
| | |
|=== Step 3: Real-Time UI Render ================================================|
| | |
| [UI Appends Message Bubble] | [UI Appends Message] |
| | |Dual-Write Pattern: REST Persistence + Socket Broadcast
In frontend/src/Components/Chat/ChatComponent.jsx, message dispatch uses a Dual-Write Architecture:
// 1. Prepare message payload
const messageData = {
content: newMessage,
chatId: chatId,
senderId: userId,
senderType: userRole,
createdAt: new Date().toISOString()
};
// 2. Step 1: Write to Database via REST (Guarantees persistence & auth)
await apiClient.post(`/chats/${chatId}/messages`,
{ content: newMessage },
{ headers: { Authorization: `Bearer ${token}` } }
);
// 3. Step 2: Emit to Socket.IO Room (Instant zero-latency delivery)
if (socket) {
socket.emit('send-message', messageData);
}Why use this Dual-Write pattern instead of sending messages purely over WebSockets?
- Reliability & Idempotency: If the WebSocket connection blips or is reconnecting, the REST call ensures the message is safely committed to disk in PostgreSQL.
- Security & Auditing: The REST controller (backend/chat/messageController.js) verifies JWT claims against the database, ensuring a malicious user cannot emit messages into rooms they don't belong to:
const isPatient = decoded.role === 'patient' && chat.patientId === parseInt(decoded.id); const isDoctor = decoded.role === 'doctor' && chat.doctorId === decoded.id; if (!isPatient && !isDoctor) { return res.status(403).json({ error: 'Forbidden' }); } - Server-Side Room Dispatch: The Socket server (backend/chat/chatSocket.js) simply broadcasts the verified payload to all sockets joined to that room:
socket.on("send-message", (messageData) => { io.to(messageData.chatId).emit("receive-message", messageData); });
Background Telemetry & Medication Alert Broadcasting
Socket.IO is also integrated with the platform's automated healthcare monitors.
In backend/app.js:
global.io = io;
configureSocketIO(io);When scheduled tasks in backend/scheduleTasks.js or middleware detect that a patient missed a dose or that new adherence telemetry has been logged, the server accesses global.io to emit:
io.emit("medications-updated", { patientId, status: "updated" });The patient's dashboard (frontend/src/Pages/PatientDashboard.jsx) listens for medications-updated and dynamically refreshes the medication checklist without requiring a page reload.
4. Concurrency Control: Solving Slot Double-Booking
The Problem: TOCTOU Race Condition
Consider Dr. Sharma who has an open slot at 10:00 AM on Monday. Two patients, Patient A and Patient B, open the booking page simultaneously.
Time Patient A (Thread 1) Patient B (Thread 2)
│
t1 findFirst(Dr. Sharma, 10:00 AM)
│ ──► Returns null (Slot is free!)
│
t2 findFirst(Dr. Sharma, 10:00 AM)
│ ──► Returns null (Slot is free!)
│
t3 create({ Dr. Sharma, 10:00 AM })
│ ──► INSERT SUCCESS!
│
t4 create({ Dr. Sharma, 10:00 AM })
│ ──► INSERT SUCCESS! (DOUBLE BOOKING BUG!)
▼This is a classic Time-of-Check to Time-of-Use (TOCTOU) race condition:
- The check (
findFirst) and the action (create) are two separate, non-atomic database operations. - Pure application-level checks (
if (!conflictingAppointment)) are completely blind to concurrent requests executing on other Node.js event-loop ticks or horizontal server instances.
The Chosen Solution: PostgreSQL Partial Unique Index
Instead of adding heavy locking infrastructure, the system utilizes PostgreSQL's native write-ahead logging and B+ Tree uniqueness guarantees via a Partial Unique Index.
The Core Concept
A standard unique index enforces uniqueness across all rows in a table. A Partial Unique Index enforces uniqueness only on a subset of rows that satisfy a specific SQL WHERE predicate.
CREATE UNIQUE INDEX "Appointment_doctorId_slotStart_active_key"
ON "Appointment" ("doctorId", "slotStart")
WHERE status <> 'cancelled';Table: Appointment
┌──────────────────┬──────────────────────┬─────────────┬────────────────────────────────────┐
│ doctorId │ slotStart │ status │ Evaluated by Partial Unique Index? │
├──────────────────┼──────────────────────┼─────────────┼────────────────────────────────────┤
│ doc-uuid-1 │ 2026-09-20 10:00:00 │ scheduled │ YES ──► Enforces Uniqueness │
│ doc-uuid-1 │ 2026-09-20 10:00:00 │ cancelled │ NO ──► Ignored by Index! │
│ doc-uuid-1 │ 2026-09-20 10:00:00 │ scheduled │ REJECTED! (Duplicate Active Slot) │
└──────────────────┴──────────────────────┴─────────────┴────────────────────────────────────┘Why is this design brilliant?
- Hardware-Enforced Atomicity: PostgreSQL's storage engine locks the index leaf node during insertion. Two concurrent transactions can never both insert active bookings for the same doctor and slot. Exactly one succeeds; the second is aborted by PostgreSQL with a unique constraint violation error.
- Slot Re-bookability without Data Loss: In healthcare systems, you cannot delete records when an appointment is cancelled (audit trail and legal compliance require retaining cancelled rows). If we had used a normal unique index on
(doctorId, slotStart), cancelling an appointment would permanently block that slot forever! TheWHERE status <> 'cancelled'clause leaves cancelled rows in the table while freeing the slot in the index for new bookings. - Zero Runtime Overhead: No external cache checks, no distributed lock managers, and no database table locks.
How the Migration Was Executed (SQL Deep Dive)
In backend/prisma/migrations/20250806120000_add_appointment_slot_unique_constraint/migration.sql:
-- Step 1: Add slotStart column (nullable during backfill to avoid locking the table)
ALTER TABLE "Appointment" ADD COLUMN "slotStart" TIMESTAMP(3);
-- Step 2: Backfill from existing appointmentDate, truncating to exact hour boundary
UPDATE "Appointment"
SET "slotStart" = date_trunc('hour', "appointmentDate")
WHERE "slotStart" IS NULL;
-- Step 3: Keep appointmentDate aligned with the normalized slot
UPDATE "Appointment"
SET "appointmentDate" = "slotStart"
WHERE "slotStart" IS NOT NULL;
-- Step 4: Resolve legacy duplicate active bookings using Window Functions before adding constraint
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY "doctorId", "slotStart"
ORDER BY "createdAt" ASC
) AS rn
FROM "Appointment"
WHERE status <> 'cancelled'
)
UPDATE "Appointment" AS a
SET status = 'cancelled'
FROM ranked AS r
WHERE a.id = r.id AND r.rn > 1;
-- Step 5: Enforce NOT NULL after backfill is complete
ALTER TABLE "Appointment" ALTER COLUMN "slotStart" SET NOT NULL;
-- Step 6: Create the Partial Unique Index
CREATE UNIQUE INDEX "Appointment_doctorId_slotStart_active_key"
ON "Appointment" ("doctorId", "slotStart")
WHERE status <> 'cancelled';Application-Level Error Handling (Prisma P2002)
In backend/appointment/createAppointment.js:
try {
// Fast-path check (optimistic check to avoid DB errors under normal traffic)
const conflictingAppointment = await prisma.appointment.findFirst({
where: {
doctorId: doctorIdStr,
slotStart,
status: { not: "cancelled" },
},
});
if (conflictingAppointment) {
return res.status(409).json({ error: "This time slot is already booked" });
}
// Attempt the insert; database partial unique index acts as the final concurrency barrier
const appointment = await prisma.appointment.create({
data: {
patientId: patientIdInt,
doctorId: doctorIdStr,
appointmentDate: slotStart,
slotStart,
status: "scheduled",
purpose: purpose || "General checkup",
},
});
return res.status(201).json(appointment);
} catch (error) {
// Catch PostgreSQL unique index violation translated by Prisma
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return res.status(409).json({ error: "This time slot is already booked" });
}
console.error("Error creating appointment:", error);
return res.status(500).json({ error: "Failed to create appointment" });
}- If two concurrent requests pass
findFirst, the first insert wins. - The losing insert fails at the PostgreSQL index level. Prisma intercepts the PostgreSQL constraint violation (
SQLSTATE 23505) and exposes it as codeP2002. - The controller catches
P2002and returns a clean, user-friendlyHTTP 409 Conflict, completely preventing data corruption.
5. Alternative Concurrency Solutions & Trade-off Matrix
While the Partial Unique Index is the optimal solution for this architecture, interviewers (especially at Juspay) love testing your ability to evaluate alternative concurrency control patterns.
Solution 1: Pessimistic Row-Level Locking (FOR UPDATE)
How it works:
Before inserting or modifying, lock the resource explicitly at the database level inside an active transaction:
BEGIN;
-- Locks all matching rows for the duration of this transaction
SELECT * FROM "Appointment"
WHERE "doctorId" = 'doc-123'
AND "slotStart" = '2026-09-20 10:00:00'
AND status <> 'cancelled'
FOR UPDATE;
-- Check if row count > 0; if 0, proceed to insert:
INSERT INTO "Appointment" (...) VALUES (...);
COMMIT;Why it was not chosen here:
- The Phantom Read Dilemma: If no row exists yet for that slot,
SELECT ... FOR UPDATEhas no row to lock! Under standard Read Committed isolation, two concurrent transactions will both see 0 rows and both proceed to insert (unless you use Predicate Locking or Gap Locking, which are expensive and complex). - Throughput Bottleneck: Acquiring database row/table locks blocks concurrent worker threads, increasing latency and connection pool pressure.
Solution 2: Optimistic Locking with Versioning (CAS)
How it works:
Treat the doctor's calendar schedule as an explicit entity with a version integer:
-- Step 1: Read doctor's schedule version
SELECT id, version FROM "DoctorSchedule" WHERE "doctorId" = 'doc-123';
-- Step 2: Update conditionally using Compare-And-Swap (CAS)
UPDATE "DoctorSchedule"
SET version = version + 1
WHERE "doctorId" = 'doc-123' AND version = 3;If rows_affected == 0, another transaction updated the schedule first; abort and retry.
Why it was not chosen here:
- Requires maintaining an artificial
DoctorScheduleor slot inventory table with pre-allocated slots for every doctor and hour of the year. - High abort-and-retry rate under peak traffic (e.g., popular doctor slot opening).
Solution 3: Distributed Locks via Redis (Redlock / SET NX)
How it works:
Acquire an exclusive lock across application servers using an in-memory Redis cluster before touching the database:
const lockKey = `lock:appointment:${doctorId}:${slotStart.getTime()}`;
// Atomically acquire lock with 5-second TTL
const acquired = await redis.set(lockKey, "locked", "NX", "EX", 5);
if (!acquired) {
return res.status(409).json({ error: "Slot currently being booked. Try again." });
}
try {
// Execute database check and insert
} finally {
// Release lock via Lua script
await releaseLock(lockKey);
}Trade-offs:
- Pros: Fast in-memory acquisition; prevents requests from even hitting the relational database.
- Cons: Introduces an external infrastructure dependency (Redis). If the Redis cluster crashes or experiences clock drift/network partitions, locks can be prematurely released, leading to race conditions. Overkill when PostgreSQL can enforce it natively.
Solution 4: Serializable Transaction Isolation
How it works:
Run the booking query inside a transaction with isolation set to SERIALIZABLE:
await prisma.$transaction(async (tx) => {
const existing = await tx.appointment.findFirst({ ... });
if (existing) throw new Error("Booked");
return await tx.appointment.create({ ... });
}, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable
});Trade-offs:
- Pros: Pure application logic without custom indexes; mathematically guarantees serial execution.
- Cons: PostgreSQL enforces Serializable isolation using SSI (Serializable Snapshot Isolation) which tracks read/write dependency locks (SIREAD locks). Under concurrent bookings, PostgreSQL will frequently throw serialization failure errors (
40001: could not serialize access), requiring complex retry logic and wasting CPU cycles.
Solution 5: Single-Threaded Job Queue / Actor Model
How it works:
Enqueue all booking requests into a message queue (e.g., BullMQ / RabbitMQ) partitioned by doctorId. A single worker process handles bookings for a given doctor sequentially.
Client Requests ──► [Queue Partition: Doctor_A] ──► [Single Worker Thread] ──► DatabaseTrade-offs:
- Pros: Completely eliminates database race conditions by eliminating concurrency altogether at the execution layer.
- Cons: Converts asynchronous HTTP requests into asynchronous polling jobs (the client must wait or poll for job completion). Dramatically increases architectural complexity.
Comprehensive Trade-off Comparison Matrix
| Concurrency Mechanism | Implementation Complexity | Latency / Performance | External Dependencies | Resiliency to High Contention | Slot Re-bookability |
|---|---|---|---|---|---|
| PostgreSQL Partial Unique Index (Chosen) | Very Low (Single SQL index + catch P2002) | Ultra-Fast (Native B+ Tree write-time check) | None (PostgreSQL native) | Highest (Deterministic serialization at disk page) | Seamless (WHERE status <> 'cancelled') |
Pessimistic Locking (FOR UPDATE) | Medium (Requires explicit transactions & gap locks) | Slow (Locks held for entire transaction duration) | None (PostgreSQL native) | Poor (Prone to deadlocks & phantom reads) | High |
| Optimistic Locking (CAS) | High (Requires versioned slot inventory tables) | Fast for low contention; slow on retries | None (PostgreSQL native) | Moderate (High abort rate on hot slots) | High |
Distributed Lock (Redis SET NX) | High (Requires Redis cluster, TTL tuning, Lua scripts) | Fast (In-memory) | High (Redis cluster) | Moderate (Risk of split-brain or TTL expiry during GC pauses) | High |
| Serializable Isolation | Low (Framework transaction configuration) | Slow (High overhead of tracking SIREAD locks) | None (PostgreSQL native) | Poor (Frequent 40001 serialization failures requiring retries) | High |
| FIFO Message Queue / Actor Model | Very High (Worker fleet, async status polling, job state) | Moderate (Queue latency + async turnaround) | High (Kafka / RabbitMQ / Redis) | High (Completely eliminates concurrency) | High |
6. Key Takeaways for Senior Engineering Interviews
- Architecture is defined by its boundaries: Synchronous REST is for the critical path where the client requires immediate consistency; asynchronous queues and WebSockets are for decoupled side-effects, real-time broadcasts, and background telemetry.
- Socket.IO is not just WebSockets: It is an entire real-time orchestration system with Engine.IO HTTP handshakes, automatic transport upgrades, heartbeat timeout detection, transparent reconnection, and in-memory room multiplexing.
- Always solve concurrency at the lowest viable layer: Application-level checks are vulnerable to TOCTOU races. Solving slot double-booking at the database engine level using a Partial Unique Index provides bulletproof concurrency guarantees with zero additional network hops or infrastructure dependencies.
- Partial indexes provide business agility: By scoping the unique index with
WHERE status <> 'cancelled', the business retains historical and audit records of cancelled appointments while immediately restoring slot availability to other patients.