In 30 seconds
Node.js runs JavaScript on the server using one main thread and an event loop, which makes it very good at handling many simultaneous network requests. NestJS is a TypeScript framework on top of Node that adds modules, dependency injection, guards and pipes, so large backends stay organised. Prisma is a type-safe database toolkit often used with both.
Why I use it
- I use Node when the whole product is TypeScript, so types and validation can be shared between API, web, desktop and mobile.
- I use NestJS because modules and dependency injection keep a large multi-tenant backend organised across dozens of domains.
- I use Node for realtime and I/O-heavy work (sockets, queues, webhooks), where its non-blocking model shines.
- I use the same runtime inside Electron, so a desktop till can share code with the server.
How it works
01The event loop in plain words
Imagine a waiter serving many tables alone. He takes an order, hands it to the kitchen and immediately moves to the next table instead of standing and waiting. When a dish is ready, the kitchen calls him. Node works the same way. Your JavaScript runs on a single main thread. When you read a file, query a database or call an API, Node hands the work to the operating system or a small thread pool (libuv) and keeps running other code. When the result is ready, a callback, or a resolved Promise, is queued and the event loop runs it. This is non-blocking I/O. The catch: if your own code does heavy CPU work, like resizing images or a huge JSON parse, the waiter is stuck in the kitchen and every other request waits.
- Phases: timers, pending callbacks, poll (I/O), check (setImmediate), close callbacks.
- Microtasks (Promise callbacks, queueMicrotask) run between each step, before the next macrotask.
- Rule: never block the loop; move CPU work to worker threads or a queue.
02When Node fits, and versions in late 2026
Node is a great fit for APIs that mostly wait on databases and other services, realtime features with many open connections, BFFs (backend-for-frontend layers), webhooks, CLIs and tooling. It is a weaker fit for CPU-heavy number crunching, where Go, Rust or a dedicated worker service do better. For production, use an LTS release: as of November 2026, Node 24 is in maintenance LTS until April 2028, Node 22 reaches end of life in April 2027, and Node 26 has just entered LTS. Modern Node includes fetch, a built-in test runner (node:test), watch mode and native TypeScript type stripping for simple cases, so you need fewer extra packages than a few years ago.
03Express vs NestJS
Express is minimal: a router and middleware, nothing else. That is perfect for a small service, but in a big codebase every team invents its own structure. NestJS gives you that structure. A module groups one domain (orders, menu, loyalty) with its controllers and providers. Dependency injection means a class declares what it needs in its constructor and Nest builds it, which makes swapping a real service for a fake in tests trivial. Around each request Nest runs a fixed pipeline: middleware, then guards (may this user call this?), then interceptors (logging, timing, response mapping), then pipes (validate and transform input), then your handler, then exception filters that turn errors into clean HTTP responses. NestJS runs on Express by default or on Fastify for more throughput.
@Controller('orders')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
@Roles('cashier', 'manager')
create(@CurrentUser() user: AuthUser, @Body() dto: CreateOrderDto) {
return this.orders.create(user.companyId, dto);
}
}
// main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
04Prisma as the data layer
Prisma lets you describe tables in a schema file and generates a fully typed client: if a column is nullable, TypeScript knows. Migrations are generated from schema changes with prisma migrate dev and applied in production with prisma migrate deploy. Use select or include to fetch only what you need, $transaction for multi-step writes, and client extensions to add cross-cutting rules, such as forcing every query to include a tenant id. Since Prisma 7, connection settings live in prisma.config.ts and the client is created with a driver adapter (for example @prisma/adapter-pg), so check the upgrade guide before moving an older project. Remember that Prisma is not magic: check the generated SQL for slow queries and add indexes in the schema.
const orders = await prisma.order.findMany({
where: { companyId, status: 'PAID', createdAt: { gte: since } },
select: {
id: true,
total: true,
customer: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'desc' },
take: 50,
});
05Worker threads, child processes and Electron
When you must do CPU work in Node, move it off the main thread. Worker threads (node:worker_threads) run JavaScript in parallel threads with their own event loop and communicate by messages; use a pool library rather than spawning a thread per request. For long or retryable work, a queue with separate worker processes (BullMQ) is usually better, because it survives restarts. Scale a Node API horizontally by running several processes (pm2 cluster mode, containers) behind a load balancer. Electron is Node plus Chromium in one desktop app: the main process has Node access, renderer windows show your React UI, and a preload script exposes a small, safe API between them. Keep contextIsolation on and nodeIntegration off in renderers.
06Production tips
Validate all input at the edge with DTOs (class-validator) or zod. Handle errors with exception filters and never let a rejected Promise go unhandled. Log as structured JSON with a request id. Implement graceful shutdown: on SIGTERM, stop accepting traffic, finish in-flight requests, close database and Redis connections. Add health checks for the load balancer. Set timeouts on every outgoing HTTP call. Keep dependencies few and audited, pin your Node version in .nvmrc or engines, and build to plain JavaScript in Docker images instead of running TypeScript through a dev tool in production.
- app.enableShutdownHooks() in NestJS so onModuleDestroy hooks run.
- Watch event-loop lag as a metric; rising lag means something is blocking.
Use it when
- Full-stack TypeScript products where sharing types across web, mobile and server saves time.
- Realtime features and I/O-heavy APIs with many concurrent connections.
- Large backends with many domains, where NestJS modules and DI keep teams consistent.
- Desktop apps (Electron) or tooling that should share code with the server.
Skip it when
- CPU-bound workloads like video encoding or heavy computation as the main job of the service.
- A team already productive in Laravel or Django on a CRUD-heavy product, where switching brings little.
- Very small services where NestJS structure is overkill; plain Fastify or Hono is enough.
Alternatives
- Laravel
Batteries-included PHP; faster for classic CRUD and admin-heavy products.
- Fastify / Hono
Lightweight Node frameworks when you want speed without Nest's structure.
- Go
Compiled, great concurrency and CPU performance; more verbose for business logic.
- Bun / Deno
Alternative JavaScript runtimes; check library compatibility before production use.
Best practices
- Never block the event loop; push CPU or slow work to worker threads or queues.
- Use a global ValidationPipe with whitelist so unknown fields are stripped.
- Keep one Nest module per domain, and export only what other modules need.
- Put authorization in guards, not in scattered if statements.
- Use Prisma select to avoid over-fetching, and transactions for multi-step writes.
- Implement graceful shutdown and health checks before going live.
- Run an LTS Node version and plan upgrades before end of life.
Common mistakes
- Running synchronous crypto, image processing or big loops inside request handlers.
- Forgetting await, so errors become unhandled rejections and data is half-written.
- Circular module dependencies in NestJS, papered over with forwardRef everywhere.
- Creating a new PrismaClient per request, exhausting database connections.
- Exposing Node APIs to Electron renderer windows with nodeIntegration enabled.
Deep dive
07Measuring a blocked event loop and the libuv thread pool
You cannot fix blocking you cannot see. Node ships monitorEventLoopDelay in node:perf_hooks: it samples how late the loop runs its timers, and a rising p99 means some synchronous code is hogging the thread. Export it as a metric next to request latency. To find the culprit, record a CPU profile with node --cpu-prof under load and open the .cpuprofile file in Chrome DevTools; the widest bars are your hot functions, often JSON.parse on huge payloads, synchronous crypto or a regex with catastrophic backtracking. A second, quieter bottleneck is libuv's thread pool, which has only 4 threads by default. File system calls, dns.lookup, zlib, pbkdf2 and scrypt, and many native hashing modules share it, so 50 concurrent password hashes queue behind each other. Raising UV_THREADPOOL_SIZE at startup helps, but moving heavy work to a worker pool is the real fix.
import { monitorEventLoopDelay } from 'node:perf_hooks';
const loop = monitorEventLoopDelay({ resolution: 20 });
loop.enable();
setInterval(() => {
const p99ms = loop.percentile(99) / 1e6; // nanoseconds to milliseconds
metrics.gauge('event_loop_delay_p99_ms', p99ms);
if (p99ms > 200) logger.warn({ p99ms }, 'event loop is blocked');
loop.reset();
}, 10_000).unref();
08Request context with AsyncLocalStorage
In PHP each request is its own process, so 'the current user' is easy. In Node, thousands of requests interleave on one thread, so a global variable would mix them up. AsyncLocalStorage from node:async_hooks solves this: you start a context at the beginning of a request and every function called from it, across awaits, timers and Promise chains, can read the same store. It is ideal for a request id in every log line, the tenant id for a Prisma extension, or a trace id for outgoing calls. In NestJS the nestjs-cls package wraps it neatly. Two rules: keep the store small and immutable-ish, and always start it in middleware, not in a guard, so everything downstream inherits it. Work started outside the request, such as a queue worker, needs its own run() call per job.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
type Ctx = { requestId: string; companyId?: string };
export const requestContext = new AsyncLocalStorage<Ctx>();
// main.ts (Express adapter)
app.use((req, _res, next) => {
const requestId = req.headers['x-request-id']?.toString() ?? randomUUID();
requestContext.run({ requestId }, next);
});
// Anywhere deeper, even after several awaits
logger.info({ requestId: requestContext.getStore()?.requestId }, 'order created');
09NestJS injection scopes and their hidden cost
By default every NestJS provider is a singleton: created once at startup and shared, which is fast and usually correct. Scope.REQUEST creates a new instance per request so it can inject the request object. The catch is that request scope bubbles up: any controller or provider that depends on a request-scoped provider also becomes request-scoped, so one innocent injection can make a whole dependency tree rebuild on every call, adding latency and garbage collection pressure. Prefer singletons that receive context as method arguments or read it from AsyncLocalStorage. If you truly need per-tenant instances, for example a separate connection per tenant, NestJS offers durable providers with a ContextIdStrategy, which keeps one sub-tree per tenant instead of per request. Dynamic modules with forRootAsync and ConfigService are the clean way to build configurable singletons at startup.
10Memory, streams and database connections
Node's heap is limited, so loading a 500 MB export into an array crashes the process with 'JavaScript heap out of memory'. Streams process data in small chunks, and stream.pipeline connects them with backpressure: if the destination is slow, the source pauses instead of buffering everything. Async generators are readable sources, so you can page through the database and write CSV lines directly. For leaks, start the process with --heapsnapshot-signal=SIGUSR2, take two snapshots minutes apart and compare what grew in DevTools; typical culprits are unbounded in-memory caches, listeners never removed and closures holding large objects. Connections are the other limited resource. Each process should create one Prisma client; with Prisma 7 driver adapters the pool size is set on the driver, such as the pg Pool max. Multiply pool size by process count and keep the total below the database limit, or put PgBouncer in front.
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
import { createWriteStream } from 'node:fs';
async function* ordersAsCsv(companyId: string) {
yield 'id,total,created_at\n';
let cursor: string | undefined;
do {
const page = await prisma.order.findMany({
where: { companyId }, take: 1000, orderBy: { id: 'asc' },
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
for (const o of page) yield o.id + ',' + o.total + ',' + o.createdAt.toISOString() + '\n';
cursor = page.at(-1)?.id;
} while (cursor);
}
await pipeline(ordersAsCsv(companyId), createGzip(), createWriteStream('/tmp/orders.csv.gz'));
Real-world scenarios
Latency spikes every few minutes for no obvious reason
Problem
The API usually answers in 40 ms, but every few minutes all requests take two or three seconds at once. CPU looks normal on average and the database is idle.
How to solve it
Everything slowing down together points to a blocked event loop, not a slow dependency. Add the event-loop delay metric and correlate spikes with logs: often a report endpoint, a big JSON export or a bulk import runs synchronously at that moment. Reproduce it under load and record node --cpu-prof to find the hot function. Then move that work off the request thread: stream the export, paginate the import, or run the CPU part in a worker thread pool such as piscina. If the work is long or must survive restarts, put it in a BullMQ queue and return a job id the client can poll.
Postgres reports too many connections after scaling out
Problem
Traffic grew, so the team went from 2 to 10 API containers. Now Postgres rejects new connections during peaks and some requests fail with connection errors.
How to solve it
Do the arithmetic: containers times processes per container times pool size. Ten containers with a pool of 20 already need 200 connections, plus workers and migrations. First check that each process creates exactly one Prisma client, not one per request or per module. Then size pools deliberately, often 5 to 10 per process, and confirm with metrics that requests do not wait on the pool. If you still need more processes, place PgBouncer in transaction mode between the apps and Postgres so thousands of client connections share a small number of server connections, and check your ORM's guidance for running behind a pooler.
Deploys drop a handful of requests every time
Problem
Each rolling deploy produces a burst of 502 errors at the load balancer. Users sometimes see a failed payment screen even though nothing in the code changed.
How to solve it
The old process is killed while it still holds connections. Handle SIGTERM properly: call app.enableShutdownHooks() in NestJS, make the readiness endpoint fail first so the balancer stops sending new traffic, wait a few seconds, then let the HTTP server close and finish in-flight requests before closing Prisma and Redis. Give the orchestrator a grace period longer than your slowest request. A second cause is keep-alive: Node's default keepAliveTimeout is 5 seconds, shorter than many load balancer idle timeouts, so the balancer reuses a socket Node just closed. Set keepAliveTimeout above the balancer's idle timeout and headersTimeout slightly above that.
Cheat sheet
- Generate a full CRUD module in NestJS
npx nest g resource orders- Record a CPU profile
node --cpu-prof dist/main.js- Take heap snapshots on demand
node --heapsnapshot-signal=SIGUSR2 dist/main.js # then: kill -USR2 <pid>- Load a .env file without a package
node --env-file=.env dist/main.js- Run the built-in test runner in watch mode
node --test --watch- Give libuv more threads
UV_THREADPOOL_SIZE=16 node dist/main.js- Apply Prisma migrations in production
npx prisma migrate deploy- Switch NestJS to Fastify
NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter())- Keep-alive longer than a 60 s load balancer
server.keepAliveTimeout = 65_000; server.headersTimeout = 66_000;
In my real projects
- Co-led a multi-tenant restaurant POS SaaS on NestJS 11, Prisma 6 and PostgreSQL: 83 domain modules, about 936 route handlers and 181 Prisma models, built in about four months by a two-person core team.
- Tenant isolation is enforced twice there: an explicit companyId in every query plus a Prisma extension that throws if the scope is missing.
- The staff till runs as an Electron desktop app with auto-update, and a separate worker process handles BullMQ queues.
- The codebase has about 270 test files on the built-in node:test runner.
Questions & answers
Q1Node is single-threaded. How does it handle thousands of requests?
Only the JavaScript runs on one thread. Network I/O is handed to the operating system's non-blocking mechanisms, and some work like file access, DNS and crypto goes to libuv's thread pool. While those operations are in flight, the event loop keeps accepting and processing other requests. When results are ready, their callbacks or Promise continuations are queued and executed. So thousands of requests waiting on a database cost very little. The limit is CPU: any synchronous heavy work blocks every request. For that you use worker threads, a queue, or more processes behind a load balancer.
Q2What is the difference between process.nextTick, Promises and setImmediate?
All three schedule work later, at different points. process.nextTick callbacks run immediately after the current operation, before other microtasks. Promise callbacks are microtasks; they run once the current call stack is empty, before the event loop moves on. setImmediate is a macrotask that runs in the check phase, after I/O polling in the current loop iteration. setTimeout with 0 runs in the timers phase of a later iteration. In practice: prefer Promises, use setImmediate to yield to I/O during long work, and avoid recursive nextTick because it can starve the loop.
Q3Why NestJS instead of plain Express?
Express gives you routing and middleware and leaves the architecture to you. That is fine for a small service, but in a large codebase each developer builds structure differently. NestJS standardises it: modules per domain, dependency injection for testability, guards for authorization, pipes for validation, interceptors for cross-cutting concerns and exception filters for consistent errors. New developers know where everything goes. It also integrates cleanly with OpenAPI, queues, websockets and microservice transports. The cost is more boilerplate and a learning curve with decorators and DI, so for a tiny service I would still pick something lighter.
Q4Explain guards, pipes and interceptors in NestJS.
They run at different stages of a request. Guards run first after middleware and answer one question: may this request continue? They are used for authentication checks and role or permission checks, and return true or throw. Interceptors wrap the handler, so they can act before and after it: logging, timing, caching, mapping the response shape or adding a timeout. Pipes transform and validate arguments right before the handler receives them, for example ValidationPipe turning a JSON body into a validated DTO or ParseIntPipe converting a route parameter. Exception filters then catch errors and format the HTTP response.
Q5When would you use worker threads?
When a Node service must do CPU-heavy work that would block the event loop: image or PDF processing, compression, parsing large files, heavy calculations. Worker threads run JavaScript in parallel with their own event loop and communicate via messages or shared memory. I use a pool rather than spawning one per request, because creating threads has a cost. If the work is long-running, should survive restarts or needs retries, I prefer a queue like BullMQ with separate worker processes. Worker threads do not help with I/O, which Node already handles without blocking.
Q6How do you prevent data leaking between tenants in a Node and Prisma backend?
Make it hard to forget the tenant filter. First, every service method takes the tenant id explicitly from the authenticated context, never from the request body, and every query includes it. Second, add a safety net: a Prisma client extension or middleware that inspects queries on tenant-owned models and throws if the tenant condition is missing, with a short allow-list for justified exceptions. Return 404, not 403, for another tenant's records so ids are not confirmed. Finally, write a wrong-tenant test for every module. Database row-level security in PostgreSQL is an extra layer worth considering.
Senior-level questions
S1How would you take a Node API from 1,000 to 20,000 requests per second?
First find the bottleneck with load tests and profiles instead of guessing. Node itself handles high I/O concurrency well, so the limits are usually CPU per process, the database and external calls. I run one process per core, through containers or cluster mode, behind a load balancer, keeping the processes stateless with sessions and caches in Redis. I move CPU-heavy work to worker pools or queues and watch event-loop delay. On the data side: indexes, fewer round trips, caching hot reads with clear invalidation, read replicas, and pooled connections sized against the database limit. Switching NestJS to the Fastify adapter and trimming JSON payloads gives a measurable gain. The trade-off is cost and complexity, so I stop when the target p99 latency is met with headroom.
S2How do you carry tenant and request context through a NestJS app?
Three options with clear trade-offs. Passing context explicitly as arguments is the most transparent and testable, and I always do it for the tenant id in service methods. Request-scoped providers can inject the request, but scope bubbles up the dependency tree and recreates many objects per request, which costs latency. AsyncLocalStorage, often through nestjs-cls, gives each request an implicit store that survives awaits, which suits cross-cutting data: request id for logs, trace ids, and a safety-net Prisma extension that checks the tenant filter. The risk is hidden coupling, so I limit what goes into the store, set it only in middleware, and still pass business-critical values explicitly. Queue jobs start their own context from the job payload.
S3How do you find and fix a memory leak in a production Node service?
Confirm it first: memory that climbs steadily across hours and does not drop after garbage collection, often ending in out-of-memory restarts. Graph heap used per process alongside traffic to rule out normal load. Then reproduce it in staging under similar traffic, start the process with --heapsnapshot-signal, take snapshots a few minutes apart and use the comparison view in DevTools to see which object types grow and what retains them. Common causes are unbounded Maps used as caches, event listeners added per request, timers never cleared, and large closures kept by long-lived objects. Fix the root cause, for example an LRU with a size limit. Restarting workers periodically is a safety net, not a fix, because it hides the next leak too.
S4When would you split a NestJS monolith into microservices?
When a module has a genuinely different need: it must scale independently, deploy on its own schedule, be owned by a separate team, or isolate risk, such as a payment integration. Without one of those reasons, microservices add network failures, distributed transactions, versioned contracts and more infrastructure, for no gain. NestJS makes the path smoother because modules already have clear boundaries and the framework has microservice transports for Redis, NATS, RabbitMQ, Kafka and gRPC. I first make the module talk to the rest only through its public service and events, then move it behind a transport while still in one repository, and only then deploy it separately. If the module cannot be isolated in-process, it will not work as a service either.
Glossary
- libuv
- The C library under Node that provides the event loop, non-blocking network I/O and a small thread pool for other work.
- Event-loop delay
- How late the loop runs scheduled callbacks. High delay means synchronous code is blocking every request.
- Microtask
- A callback, such as a Promise continuation, that runs right after the current code finishes, before timers or I/O.
- Provider
- Any class NestJS can inject: services, repositories, factories or values, registered in a module.
- Injection scope
- How long a NestJS provider instance lives: singleton by default, or per request, or transient per injection.
- Dynamic module
- A NestJS module configured at import time, like forRoot() or forRootAsync(), returning providers built from options.
- AsyncLocalStorage
- A Node API that carries a per-request store through async calls without passing it as an argument.
- Backpressure
- A slow consumer signalling the producer to pause, so data is not buffered in memory without limit.
- Driver adapter
- In Prisma 7, the package connecting the generated client to a database driver such as pg, which also owns the pool.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.