Determine whether the deployed ta-course-match web app can handle 150 concurrent users on its current VM, identify the bottleneck(s), and recommend concrete config changes. Produce findings + a tuning patch.
- Host: KVM guest
ta-match, Ubuntu 24.04.1, kernel 6.8, x86_64 - CPU: 4 vCPUs (QEMU virtual, 1 thread/core), currently near-idle
- RAM: 3.8 GiB total. ~2.0 GiB used at baseline, ~1.8 GiB available headroom. RAM is the binding resource, not CPU or disk.
- Swap: 3.8 GiB swapfile (slow — avoid relying on it)
- Disk: 100 GB ext4, 38% used (58 GB free) — not a constraint
- No GPU
| Container | RAM | CPU | Notes |
|---|---|---|---|
| platform-nginx-1 | 9.3 MiB | 0.00% | reverse proxy |
| platform-backend-1 | 498.6 MiB | 0.22% | main consumer, the app to scrutinize |
| postgres-app-1 | 36.0 MiB | 0.02% | |
| postgres-db-1 | 19.0 MiB | 0.00% | |
| Total | ~563 MiB | idle |
Critical: all four containers show LIMIT 3.824GiB — i.e. no per-container memory caps set. Any one can OOM the whole VM.
Inspect the codebase and config in this priority order. The likely bottleneck ranking is: DB connection pool > RAM under load > CPU.
-
Database connection pool (most likely first wall)
- Find the pool config (
pg.Pool, Prisma, TypeORM, Sequelize, etc.). Report themaxpool size. Default for node-postgres is 10 — with 150 concurrent requests holding connections, requests queue behind the pool. - Find Postgres
max_connections(default 100). Check bothpostgres-app-1andpostgres-db-1— clarify what each is for. - Check whether requests hold a connection for their full lifetime or release promptly. Flag any long-held transactions.
- Check for N+1 query patterns in the matching logic.
- Find the pool config (
-
Where the matching/compute work runs
- Is the TA-to-course matching done in SQL (good, DB does the work) or in Node (CPU-bound, blocks the single-threaded event loop)?
- If in Node: is it inside the request handler synchronously? That serializes under load. Flag it.
-
Process model / concurrency
- Is the backend a single Node process, or clustered (
node:cluster, PM2, or multiple replicas)? Single process = one core for JS execution out of 4 available. Recommend clustering to ~3 workers if CPU-bound, leaving headroom for DB + OS. - Identify framework (Express / Fastify / Nest) and any per-request memory hot spots (loading full result sets into memory, large JSON serialization).
- Is the backend a single Node process, or clustered (
-
Memory math under load
- Backend baseline ~500 MiB. Estimate per-connection / per-request memory growth.
- Budget: must stay under ~1.8 GiB available, ideally leave >1.2 GiB free. Confirm 150 concurrent does not push into swap.
The verdict hinges on usage pattern:
- 150 users with sessions open, occasional clicks (realistic for a per-term TA matching tool): ~5–20 req/s peak. Almost certainly fine on this hardware.
- 150 users firing requests in the same second: real load; pool size and event-loop throughput decide it.
State which model the analysis assumes and ideally test the worst case.
Install k6 and test the real endpoint(s). Run from the VM against localhost to exclude network.
curl -fsSL https://github.com/grafana/k6/releases/latest/download/k6-linux-amd64.tar.gz | tar xz
cat > load.js <<'EOF'
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
realistic: { executor: 'constant-vus', vus: 150, duration: '60s' },
},
};
export default function () {
// TODO: replace with the actual hot path (e.g. the matching/list endpoint)
const res = http.get('http://localhost:5001/');
check(res, { 'status 200': (r) => r.status === 200 });
sleep(1); // models think-time; remove for worst-case hammer test
}
EOF
./k6 run load.jsWhile k6 runs, capture resource behavior in a second shell:
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}'
free -hReport from k6 summary: req/s, p95 + p99 latency, error rate, and whether RAM stayed off swap. Run twice — once with sleep(1) (realistic) and once without (worst case).
- Verdict: can it handle 150 concurrent users, for each usage model. Yes/no with the limiting number (e.g. "pool=10 caps throughput at X req/s").
- Bottleneck table: ranked, with the measured evidence.
- Tuning patch: concrete diffs for
- DB pool
maxand Postgresmax_connections(sized together, not blindly raised — more connections = more RAM) - Per-container memory limits in the compose file (cap backend ~1 GiB, others smaller) so no container can OOM the host
- Clustering/replicas if CPU-bound
- DB pool
- Before/after load-test numbers if changes are applied.
Add memory limits in the compose file — currently none exist:
services:
platform-backend:
deploy:
resources:
limits:
memory: 1g
# smaller caps for nginx, postgres-app, postgres-dbUse --memory-swap = --memory semantics so a runaway container gets OOM-killed instead of dragging the whole VM into swap thrashing.
- Do not assume cloud autoscaling — this is a single fixed 4 vCPU / 3.8 GiB VM.
- Raising pool/connection counts costs RAM; every change must fit the ~1.8 GiB budget.
- Verify findings with the load test; do not report a verdict from static reading alone.