Chapter 114 โ Scalable APIs
๐ Definition
A scalable API is one whose throughput and latency degrade gracefully as users, data, or traffic grow.
๐ Layers of Scaling
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Clients (web / mobile) โ
โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CDN โ โ cache static + cacheable API responses
โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Load Balancer โ โ round-robin / least-conn
โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ API Gateway (optional) โ โ auth, rate-limit, routing
โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Stateless API Instances โ โ horizontally scalable
โโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโ
โผ โผ
โโโโโโโโโโโโ โโโโโโโโโโโโโโ
โ Cache โ โ Queue โ
โ (Redis) โ โ (BullMQ) โ
โโโโโโโฌโโโโโ โโโโโโฌโโโโโโโโ
โผ โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Database (Mongo) โ
โ Replicas, Sharding โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ๐ป Code Example โ Statelessness
Stateless services scale horizontally. Avoid:
- In-process session storage.
- Local file uploads.
- Long-lived WebSocket connections without sticky routing.
// โ in-process map โ won't survive a restart, not shared across instances
const sessions = new Map();
// โ
Redis โ shared, durable
import { createClient } from "redis";
const redis = createClient();
await redis.connect();
await redis.set(`session:${id}`, JSON.stringify(data), { EX: 3600 });๐ Caching Strategies
| Layer | What to cache | TTL |
|---|---|---|
| Browser | Static assets, GET responses with Cache-Control |
hours / days |
| CDN | Same as above; geo-distributed | hours / days |
App-process (lru-cache) |
Hot lookups inside one instance | seconds / minutes |
| Redis | Shared cache for multi-instance | seconds / minutes / hours |
| DB query cache | Materialized views, repeated heavy aggregates | hours |
Cache-Aside (most common)
async function getUser(id) {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const user = await User.findById(id).lean();
if (user) await redis.set(key, JSON.stringify(user), { EX: 300 });
return user;
}
// Invalidate on writes
async function updateUser(id, patch) {
const user = await User.findByIdAndUpdate(id, patch, { new: true });
await redis.del(`user:${id}`);
return user;
}๐ป Code Example โ Queue / Background Jobs
Anything that's slow, retryable, or doesn't need a sync response โ queue it.
// producer (in API)
import { Queue } from "bullmq";
const emailQ = new Queue("email", { connection: { host: "redis" } });
app.post("/users", async (req, res) => {
const user = await UserService.create(req.body);
await emailQ.add("welcome", { to: user.email }); // returns immediately
res.status(201).json(user);
});
// worker (separate process)
import { Worker } from "bullmq";
new Worker("email", async (job) => {
await sendEmail(job.data.to, "Welcome!");
}, { connection: { host: "redis" } });Common use cases:
- Email / SMS / push notifications.
- Image resizing.
- Bulk imports/exports.
- Webhook delivery (with retries).
๐ป Code Example โ Database Read Replicas
// In mongoose, use readPreference for analytics queries
Order.find({})
.read("secondaryPreferred") // read from replica
.lean();๐ป Code Example โ Pagination & Filtering at Scale
// Cursor pagination (Chapter 36) โ required at scale
GET /messages?after=<id>&limit=20Always:
- Index the cursor field.
- Cap
limitserver-side (no?limit=1000000). - Avoid
countfor huge collections (use approximations).
๐ป Code Example โ Connection Pooling
mongoose.connect(url, {
maxPoolSize: 50, // tune based on load tests
minPoolSize: 5,
serverSelectionTimeoutMS: 5000,
});For Postgres, use pg-pool or PgBouncer in front.
๐ป Code Example โ Observability
import pino from "pino";
const logger = pino({ level: "info" });
// Structured logs
logger.info({ userId: user.id, route: req.url }, "Request handled");
// Metrics with prom-client
import client from "prom-client";
const reqDuration = new client.Histogram({
name: "http_request_duration_ms",
help: "Request duration in ms",
labelNames: ["method", "route", "status"],
});
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () =>
reqDuration.labels(req.method, req.route?.path || "", res.statusCode)
.observe(Date.now() - start)
);
next();
});
// Expose /metrics for Prometheus scraping
app.get("/metrics", async (req, res) => {
res.set("Content-Type", client.register.contentType);
res.end(await client.register.metrics());
});Pair with OpenTelemetry for distributed tracing across services.
๐ป Code Example โ Graceful Shutdown
const server = app.listen(port);
const shutdown = async () => {
console.log("Shutting down");
server.close(); // stop accepting new requests
await mongoose.disconnect();
await redis.quit();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);๐ Scaling Patterns Summary
| Symptom | Solution |
|---|---|
| One DB row is hot | Cache (Redis), denormalize |
| Same query repeated | Cache results |
| CPU-bound endpoint | Offload to worker thread or queue |
| Slow third-party call | Async queue + webhook callback |
| 10k connections per instance | Add instances behind LB |
| One DB is saturated | Add read replicas / shard |
| Static assets slow | CDN |
| Cold starts | Pre-warm or keep min instances |
๐ฏ Likely Interview Questions
- How would you scale this API to 10ร traffic?
- Where would you add caching?
- When would you introduce a queue?
- How do you keep services stateless?
- What metrics matter for a healthy API? โ Request rate (RPS), error rate, latency (p50, p95, p99), saturation (CPU, memory, DB connections). The RED method (Rate, Errors, Duration).