06-scaling-and-production.md

Scaling and Production — How to Scale This

Current Bottlenecks (What Breaks First)

1. Medicine CSV Parsing (Breaks at ~50 concurrent users)

The /api/medicines?search= endpoint re-reads and parses a 32MB CSV file on every request. At 50 concurrent search requests, you're reading 1.6GB from disk simultaneously. Node.js's event loop gets blocked during CSV parsing.

Fix: Import CSV into PostgreSQL with a GIN index for full-text search. One-time import, sub-millisecond queries forever.

2. Medication Reminder N+1 Query Pattern (Breaks at ~500 patients)

sendReminders() in scheduleTasks.js:

For EACH patient with prescriptions: 1. Query prescriptions + medicines 2. Query existing adherence records 3. Filter untaken meds 4. Send email

At 500 patients: 1,500+ queries per reminder cycle (3 per patient). At 8 AM, the cron runs, hammers the DB for minutes, and Prisma Accelerate connection pool exhausts.

Fix: Single batch query: SELECT patients with untaken medications for $timeOfDay WHERE NOT already reminded today — one query instead of 1,500.

3. MedicineAdherence Table Growth (Breaks at ~6 months)

With 1,000 patients × 5 medicines × 3 doses/day = 15,000 rows/day. After 6 months: 2.7M rows. The daily medication query (WHERE scheduledDate = today) scans the entire table.

Fix: Add composite index on (patientId, scheduledDate). Long-term: partition by month or archive old records.

4. Socket.IO on Serverless (Already Broken)

Vercel serverless functions are stateless — they can't maintain WebSocket connections. Chat is non-functional in production on Vercel.

Fix: Deploy the backend to a persistent server (Railway, Render, AWS EC2) or replace Socket.IO with a managed service (Pusher, Ably).

5. Single-Process Architecture

Express + Socket.IO + node-cron all run in a single Node.js process. If any module throws an unhandled exception, everything goes down — chat, cron, and API.

Fix: Separate into 3 services: API server, WebSocket server, cron worker.


How to Scale Each Component

Frontend Scaling

Current: Vite dev server / static build ↓ At 10k users: CDN-hosted static assets (Vercel/Cloudflare Pages) ↓ At 100k users: Same, plus: - Code splitting per route (React.lazy) - Service worker for offline medication view - Image/asset optimization (WebP, lazy loading)

Frontend scales almost for free — it's static files. The only concern is API request volume from the browser.

Backend Scaling

Current: Single Express server on port 8000 ↓ Phase 1 (1k users): Deploy to Railway/Render with auto-scaling ↓ Phase 2 (10k users): ├─ API Server (stateless, horizontally scaled) ├─ WebSocket Server (sticky sessions or managed service) └─ Cron Worker (single instance, BullMQ + Redis) ↓ Phase 3 (100k users): ├─ API Gateway (Kong/AWS API Gateway) with rate limiting ├─ Auto-scaling API pods (Kubernetes/ECS) ├─ Managed WebSocket (Pusher/Ably) └─ Job queue (BullMQ with Redis Cluster)

Database Scaling

Current: Neon PostgreSQL (single instance, connection pooling via Accelerate) ↓ At 1k users: ├─ Add missing indexes (patientId+scheduledDate, doctorId+appointmentDate) ├─ Connection pooling via PgBouncer (if moving off Accelerate) └─ Query optimization (batch queries, eliminate N+1) ↓ At 10k users: ├─ Read replicas for medication history and adherence stats ├─ Partition MedicineAdherence by month └─ Archive records older than 1 year to cold storage ↓ At 100k users: ├─ Sharding by patient ID (each shard owns a patient's data) ├─ Separate analytics DB (read replica or data warehouse) └─ Materialized views for adherence stats (precomputed daily)

Caching Strategy

What to Cache

DataCache TTLInvalidationWhy
Medicine search results24hOn CSV update (essentially never)32MB CSV doesn't change frequently
Doctor's patient list5 minOn patient add/remove/assignLow write frequency, high read frequency
Available appointment slots30 secOn appointment create/cancelHighly dynamic, must be fresh
Today's medicationsNo cacheN/AMust reflect real-time status changes
Adherence stats15 minOn medication status updateExpensive aggregation query

Implementation

Phase 1: In-memory cache (Map/LRU) for medicine data - Load CSV once on startup - Store in memory (~50MB for full dataset) - Search via Array.filter() (current approach but only once) Phase 2: Redis cache - Cache doctor patient lists: key = `doctor:${id}:patients` - Cache appointment slots: key = `slots:${doctorId}:${date}` - Cache adherence stats: key = `adherence:${patientId}:${days}` - Invalidate on writes via pub/sub or explicit cache.del() Phase 3: Read-through cache pattern - API checks Redis → if miss → query DB → store in Redis → return - Background job refreshes hot keys

Horizontal Scaling Requirements

To run multiple API server instances:

ConcernCurrent StateWhat Needs to Change
Database connectionsPrisma Accelerate pools connections✅ Already handled
Session stateJWT (stateless)✅ Already stateless
File systemCSV read from local disk❌ Either import to DB or use shared storage
Cron jobsIn-process node-cron❌ Must move to external scheduler or single cron instance
Socket.IOIn-process, global io❌ Need Redis adapter for multi-instance Socket.IO, or managed service
Medication reminder cooldownIn-memory lastReminderSent object❌ Must move to Redis or shared state

Socket.IO at Scale

// Current: Single instance const io = new Server(server); // Multi-instance: Redis adapter import { createAdapter } from "@socket.io/redis-adapter"; import { createClient } from "redis"; const pubClient = createClient({ url: REDIS_URL }); const subClient = pubClient.duplicate(); io.adapter(createAdapter(pubClient, subClient));

Load Balancer Considerations

StrategyApplicability
Round-robinWorks for API server (stateless JWT)
Sticky sessionsRequired for Socket.IO (if not using Redis adapter)
Least connectionsBetter for medication reminder cron (avoids sending all reminders from one instance)

Recommended: Sticky sessions for the WebSocket endpoint, round-robin for everything else.


Queue-Based Architecture

Operations that should be async at scale:

OperationCurrentShould Be
Send invite emailFire-and-forget in request handlerJob queue (retry on failure)
Send medication reminderCron → loop over patients → sendJob queue: enqueue one job per patient
Mark missed medicationsCron → loop over records → update eachBatch UPDATE with single SQL query
Prescription delete cascadePrisma transaction in requestJob queue (for large datasets)

BullMQ Architecture

┌──────────┐ ┌─────────┐ ┌──────────────┐ │ API │────→│ Redis │←────│ Worker │ │ Server │ │ (Queue) │ │ (BullMQ) │ │ │ └─────────┘ │ │ │ Enqueue: │ │ Process: │ │ - emails │ │ - send email │ │ - reminders│ │ - mark missed│ │ - reports│ │ - generate │ └──────────┘ │ reports │ └──────────────┘

Rate Limiting

Current State

No rate limiting at all. Any client can send unlimited requests. The /admin/send-medication-reminders endpoint is completely unprotected.

At Scale

Phase 1: Express rate limiter (express-rate-limit) - Global: 100 requests/minute per IP - Auth endpoints: 5 requests/minute per IP (brute-force protection) - Medicine search: 30 requests/minute per IP Phase 2: Redis-based rate limiting - Per-user rate limiting (by JWT user ID) - Sliding window algorithm - Distributed across multiple server instances Phase 3: API Gateway rate limiting - Kong or AWS API Gateway - Rate limiting, throttling, and quota management - DDoS protection

Monitoring and Alerting Strategy

Metrics to Track

MetricWhyAlert Threshold
API response time (p95)Detect degradation> 500ms
DB query time (p95)Detect slow queries> 200ms
MedicineAdherence table sizeCapacity planning> 1M rows
Email send failuresReminder delivery> 5% failure rate
Unhandled exceptionsStabilityAny
WebSocket connection countChat capacity> 1000 concurrent
Cron job execution timeReminder performance> 30 seconds
JWT verification failuresPotential attack> 50/minute

Implementation

Phase 1: Console logging (current) + Sentry for error tracking Phase 2: Prometheus + Grafana for metrics dashboards Phase 3: DataDog or New Relic for full observability (APM, logs, metrics, traces)

Estimated Cost at Scale

UsersInfrastructureDBEmailMonthly Cost
1kRailway ($5/mo) or Render free tierNeon free tier (0.5 GB)Gmail free (500/day)$5-10/mo
10kRailway Pro ($20/mo) + Redis ($10/mo)Neon Pro ($19/mo, 10 GB)SendGrid free (100/day) or $20/mo$50-70/mo
100kAWS ECS ($100/mo) + ALB ($20/mo) + Redis ($25/mo)RDS PostgreSQL ($50/mo, 100 GB)SendGrid Pro ($90/mo)$300-500/mo

The ONE Thing That Would Take Down the System

The medication reminder cron at 8 AM.

At 8 AM, sendReminders("morning") fires. It:

  1. Queries ALL patients with prescriptions (full table scan)
  2. For EACH patient, runs 3-4 additional queries
  3. For each untaken medicine, sends an email
  4. For each email, creates/updates MedicineAdherence records

At 1,000 patients with 5 medicines each: ~4,000 DB queries + ~5,000 email sends, all in a single synchronous loop. The Prisma Accelerate connection pool (default 5 connections) gets exhausted. Pending queries queue up. The API server becomes unresponsive for ALL users — not just medication-related requests — because the connection pool is shared.

The cascade: DB pool exhausted → API requests timeout → frontend shows errors → patients can't mark meds as taken → more meds get marked as missed → more emails get queued → email sending fails → Nodemailer retries consume more resources.

Fix: Dedicated worker process with a job queue. Each patient gets their own job. Jobs process in parallel with bounded concurrency (e.g., 10 at a time). API server's DB pool is isolated.