Intro
Node.js can feel deceptively simple: one thread, lots of speed. The reality is a well-defined runtime with clear rules. This guide explains the architecture, shows how data and control move through an app, and highlights failure points and operational tradeoffs. You will get a full working pattern using Express for HTTP, MongoDB for storage, Redis for caching, Docker for packaging, and a minimal GitLab CI/CD example.
What you will learn:
- Node.js core components and how the event loop works
- A clean layering model for routes, controllers, services, repositories, and clients
- Data flow, control flow, and where to put boundaries
- Failure points to watch and how to mitigate them
- A safe, measurable local pilot you can run today
Architecture fundamentals
At a high level, Node.js is:
- Single-threaded JavaScript execution on V8.
- Event-driven I/O on libuv with a small thread pool for filesystem, DNS, crypto, and some compression.
- Promise microtasks run between event loop turns.
- Streams and backpressure are first-class for efficient I/O.
- Worker Threads can isolate CPU-bound tasks.
- Multiple processes or container replicas provide horizontal scale.
Key runtime elements:
- V8: compiles and optimizes JS.
- libuv: event loop, timers, poll, check, and close phases. It also manages a thread pool for blocking syscalls.
- Event loop: schedules callbacks when I/O completes. Keep it free of long-running JS.
- Microtasks: queued by Promises; they run before the next macrotask turn. Avoid starved loops by not chaining huge microtask queues.
- Node APIs: http, net, tls, fs, stream, worker_threads, cluster, process, buffer.
Practical rules:
- Never block the event loop with CPU-heavy work. Use worker_threads or a separate service.
- Set timeouts on every outbound call (HTTP, DB, cache) and on the server.
- Use streams and backpressure for large payloads.
- Log in structured JSON and emit metrics.
Data and control flow
A typical request path in a well-structured Node.js service:
- Ingress: client -> reverse proxy (eg, Nginx) -> Node process.
- Routing: Express matches a route and calls a controller.
- Controller: validates input, calls a service, maps errors to HTTP.
- Service: applies business rules, composes repositories and caches.
- Repository: executes DB queries and returns plain objects.
- Cache: short-circuits reads and protects the DB.
- Response: controller sends result, often as JSON; for large responses, use streams.
Control flow is asynchronous. I/O does not block JS; callbacks or await resume when ready. CPU-bound work must be offloaded to keep latency stable.
Practical example architecture
We will build a minimal user read API with caching.
Folder layout:
app/
src/
server.js
routes/userRoutes.js
controllers/userController.js
services/userService.js
repos/userRepo.js
clients/mongo.js
clients/redis.js
middleware/errors.js
util/http.js
package.json
server.js:
const http = require('http');
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const { errorHandler } = require('./middleware/errors');
const app = express();
app.use(express.json());
app.get('/healthz', (req, res) => res.json({ ok: true }));
app.use('/v1/users', userRoutes);
app.use(errorHandler);
const server = http.createServer(app);
// Sensible timeouts to avoid slowloris and hung sockets
server.keepAliveTimeout = 65000; // ms
server.headersTimeout = 67000; // ms
const port = process.env.PORT || 3000;
server.listen(port, () => {
console.log(JSON.stringify({ level: 'info', msg: `listening on :${port}` }));
});
routes/userRoutes.js:
const express = require('express');
const { getUserById } = require('../controllers/userController');
const router = express.Router();
router.get('/:id', getUserById);
module.exports = router;
controllers/userController.js:
const { findUser } = require('../services/userService');
async function getUserById(req, res, next) {
try {
const id = req.params.id;
if (!id || id.length < 3) {
return res.status(400).json({ error: 'invalid id' });
}
const user = await findUser(id);
if (!user) return res.status(404).json({ error: 'not found' });
return res.json(user);
} catch (err) {
return next(err);
}
}
module.exports = { getUserById };
services/userService.js:
const { getUserById } = require('../repos/userRepo');
const cache = require('../clients/redis');
const USER_TTL_SECONDS = 60;
async function findUser(id) {
const cacheKey = `user:${id}`;
const cached = await cache.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await getUserById(id);
if (user) {
await cache.set(cacheKey, JSON.stringify(user), { EX: USER_TTL_SECONDS });
}
return user;
}
module.exports = { findUser };
repos/userRepo.js:
const { getDb } = require('../clients/mongo');
async function getUserById(id) {
const db = await getDb();
return db.collection('users').findOne({ _id: id }, { projection: { _id: 1, name: 1, email: 1 } });
}
module.exports = { getUserById };
clients/mongo.js:
const { MongoClient } = require('mongodb');
const uri = process.env.MONGO_URI || 'mongodb://localhost:27017/app';
const client = new MongoClient(uri, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 3000
});
let db;
async function getDb() {
if (!db) {
await client.connect();
db = client.db();
}
return db;
}
module.exports = { getDb };
clients/redis.js:
const { createClient } = require('redis');
const client = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
client.on('error', (err) => {
console.log(JSON.stringify({ level: 'error', msg: 'redis error', err: String(err) }));
});
let initPromise;
async function ensureReady() {
if (!initPromise) initPromise = client.connect();
return initPromise;
}
module.exports = {
async get(key) { await ensureReady(); return client.get(key); },
async set(key, value, opts) { await ensureReady(); return client.set(key, value, opts); }
};
middleware/errors.js:
function errorHandler(err, req, res, next) { // eslint-disable-line
const status = err.statusCode || 500;
const payload = { error: status === 500 ? 'internal error' : err.message };
console.log(JSON.stringify({ level: 'error', msg: 'request error', status, err: String(err) }));
res.status(status).json(payload);
}
module.exports = { errorHandler };
Offloading CPU-bound work
If you must do heavy CPU tasks, use worker_threads:
// workers/hash.js
const { parentPort, workerData } = require('worker_threads');
const crypto = require('crypto');
const result = crypto.pbkdf2Sync(workerData.pwd, 'salt', 100000, 64, 'sha512').toString('hex');
parentPort.postMessage(result);
// in a controller or service
const { Worker } = require('worker_threads');
function hashPassword(pwd) {
return new Promise((resolve, reject) => {
const w = new Worker(require.resolve('./workers/hash'), { workerData: { pwd } });
w.once('message', resolve);
w.once('error', reject);
w.once('exit', (code) => { if (code !== 0) reject(new Error('worker exit ' + code)); });
});
}
Workflow Overview
A clear workflow reduces rework and aligns code with operations:
- Model the API and data boundaries. Define route contracts and error shapes.
- Implement layers with small, testable functions. Keep controllers thin.
- Add safeguards first: server and client timeouts, input validation, JSON logging.
- Add caching where reads dominate. Choose conservative TTLs and validate cache keys.
- Containerize. Use a small base image and a non-root user.
- Automate tests and builds with GitLab CI/CD.
- Deploy with multiple replicas behind a reverse proxy. Use readiness probes.
- Observe and tune: watch p95 latency, saturation, error rates, and memory.
Example Dockerfile:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S nodejs && adduser -S node -G nodejs
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package*.json ./
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
Minimal .gitlab-ci.yml:
stages: [test, build]
node: test:
image: node:20-alpine
stage: test
script:
- npm ci
- npm test -- --ci
docker: build:
image: gcr.io/kaniko-project/executor: latest
stage: build
script:
- /kaniko/executor --destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" --context "$CI_PROJECT_DIR" --dockerfile Dockerfile
only:
- main
Local Pilot Plan
Start small so you can measure and iterate quickly.
Scope:
- One endpoint: GET /v1/users/:id
- Data in MongoDB users collection
- Redis cache with 60 s TTL
- Health check: GET /healthz
Environment with Docker Compose:
aVersion: '3.9'
services:
app:
build: .
ports: ["3000:3000"]
environment:
- MONGO_URI=mongodb://mongo:27017/app
- REDIS_URL=redis://redis:6379
depends_on: [mongo, redis]
mongo:
image: mongo:7
ports: ["27017:27017"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
Seed data (run once in a shell):
mongo <<'EOF'
use app
db.users.insertOne({ _id: 'u1', name: 'Ada', email: '[email protected]' })
EOF
Acceptance criteria:
- Correctness: returns 200 with user for valid id, 404 otherwise.
- Latency: p95 < 50 ms for cached reads locally, < 150 ms for cold reads.
- Stability: no unhandledRejection, no uncaughtException.
- Resource: memory steady under light load.
Measure with autocannon:
npx autocannon -c 50 -d 20 http://localhost:3000/v1/users/u1
Inspect cache hit rate by logging when a cache read returns or sets a value. Increase TTL if DB load is high and data is stable; decrease TTL if staleness is a concern.
Operations and tradeoffs
Common failure points and mitigations:
- Event loop blocking: Avoid sync crypto, zlib, or large JSON operations on hot paths. Offload to worker_threads. Use setImmediate in long loops to yield.
- Missing timeouts: Set server headersTimeout and keepAliveTimeout; set client timeouts on Mongo and Redis.
- Unbounded concurrency: Use p-limit style control for heavy tasks or queue work.
- Backpressure: Use stream.pipeline for file uploads/downloads. Do not buffer entire payloads in memory.
- Cache stampedes: Use request coalescing and jittered TTLs to reduce thundering herds.
- Error handling: Convert domain errors to 4xx; treat unexpected errors as 5xx. Log with correlation ids if available.
- Shutdown: Handle SIGTERM, stop accepting new connections, and wait for in-flight requests to drain.
Tradeoffs:
- Single process vs replicas: Replicas add resilience and parallelism. Prefer containers or multiple processes behind a proxy rather than in-process cluster if you already orchestrate containers.
- Worker threads vs separate services: Threads reduce latency for short CPU tasks; separate services isolate failures and allow independent scaling for heavy workloads.
- Caching strategy: Read-through is simple; write-through or write-behind improve consistency or throughput depending on needs.
Quick checklist:
- [ ] Timeouts on server and all clients
- [ ] Health, readiness, and liveness endpoints
- [ ] Structured logs and basic metrics
- [ ] Backpressure on streams
- [ ] Safe shutdown handlers
Conclusion
You now have a practical blueprint for Node.js architecture:
- Understand the event loop and keep it free of CPU-heavy work.
- Structure code into routes, controllers, services, repositories, and clients.
- Put timeouts, logging, and caching in from the start.
- Package with Docker and automate tests and builds with GitLab CI/CD.
- Start with a narrow local pilot, measure p95 latency and resource use, then iterate.
Adopt these patterns incrementally. Keep concerns separated to reduce rework, build the minimal pilot first, and extend only when the data shows you need to.