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 emailAt 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
| Data | Cache TTL | Invalidation | Why |
|---|---|---|---|
| Medicine search results | 24h | On CSV update (essentially never) | 32MB CSV doesn't change frequently |
| Doctor's patient list | 5 min | On patient add/remove/assign | Low write frequency, high read frequency |
| Available appointment slots | 30 sec | On appointment create/cancel | Highly dynamic, must be fresh |
| Today's medications | No cache | N/A | Must reflect real-time status changes |
| Adherence stats | 15 min | On medication status update | Expensive 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 keysHorizontal Scaling Requirements
To run multiple API server instances:
| Concern | Current State | What Needs to Change |
|---|---|---|
| Database connections | Prisma Accelerate pools connections | ✅ Already handled |
| Session state | JWT (stateless) | ✅ Already stateless |
| File system | CSV read from local disk | ❌ Either import to DB or use shared storage |
| Cron jobs | In-process node-cron | ❌ Must move to external scheduler or single cron instance |
| Socket.IO | In-process, global io | ❌ Need Redis adapter for multi-instance Socket.IO, or managed service |
| Medication reminder cooldown | In-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
| Strategy | Applicability |
|---|---|
| Round-robin | Works for API server (stateless JWT) |
| Sticky sessions | Required for Socket.IO (if not using Redis adapter) |
| Least connections | Better 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:
| Operation | Current | Should Be |
|---|---|---|
| Send invite email | Fire-and-forget in request handler | Job queue (retry on failure) |
| Send medication reminder | Cron → loop over patients → send | Job queue: enqueue one job per patient |
| Mark missed medications | Cron → loop over records → update each | Batch UPDATE with single SQL query |
| Prescription delete cascade | Prisma transaction in request | Job 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 protectionMonitoring and Alerting Strategy
Metrics to Track
| Metric | Why | Alert Threshold |
|---|---|---|
| API response time (p95) | Detect degradation | > 500ms |
| DB query time (p95) | Detect slow queries | > 200ms |
| MedicineAdherence table size | Capacity planning | > 1M rows |
| Email send failures | Reminder delivery | > 5% failure rate |
| Unhandled exceptions | Stability | Any |
| WebSocket connection count | Chat capacity | > 1000 concurrent |
| Cron job execution time | Reminder performance | > 30 seconds |
| JWT verification failures | Potential 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
| Users | Infrastructure | DB | Monthly Cost | |
|---|---|---|---|---|
| 1k | Railway ($5/mo) or Render free tier | Neon free tier (0.5 GB) | Gmail free (500/day) | $5-10/mo |
| 10k | Railway Pro ($20/mo) + Redis ($10/mo) | Neon Pro ($19/mo, 10 GB) | SendGrid free (100/day) or $20/mo | $50-70/mo |
| 100k | AWS 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:
- Queries ALL patients with prescriptions (full table scan)
- For EACH patient, runs 3-4 additional queries
- For each untaken medicine, sends an email
- 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.