Node.js Development

Node.js is the runtime we reach for when a service spends most of its life waiting — on a database, a payment provider, a queue, another team's API. It is not the runtime we reach for when a service spends its life computing. This page is about where that line sits, how we build on the correct side of it, and what we do about the npm dependency tree that comes attached.

What Node.js is

Node.js is a server-side JavaScript runtime built on Google's V8 engine. Your code runs on a single event loop while I/O — sockets, file reads, database round trips — is handed to the operating system and picked up again through callbacks, so one process can hold a very large number of concurrent connections without a thread per connection. That design is what makes Node quick at work that waits and poor at work that calculates.

Prefer the short definition? Read the Node.js glossary entry.

The honest version

When Node.js is the right choice — and when it is not

Choosing a technology because it is popular is how projects end up expensive. Here is where we would pick it, and where we would tell you not to.

Good fit

I/O-bound APIs and backend-for-frontend layers
A request that calls three upstream services and a database is idle for most of its wall-clock life. Node handles that idleness with a socket and a closure rather than a parked thread, so one modest container sustains high concurrency on a flat memory curve. Fan-out with Promise.all is the natural shape of the code, not a threading exercise.
Real-time transports — WebSocket, SSE, presence, live dashboards
Long-lived connections cost memory, not threads. Tens of thousands of mostly-idle sockets in one process is realistic when per-connection state is small, and the same process can push updates without a separate streaming tier. Collaborative editing, order and delivery status feeds, and chat all sit here comfortably.
Integration and webhook layers
Payment gateways, KYC providers, logistics APIs, CRMs and hospital middleware nearly all ship a maintained JavaScript SDK, and the work itself is orchestration: verify a signature, deduplicate on an idempotency key, transform, enqueue, retry. Node gets you to a correct adapter faster than a runtime where you would first have to write the HTTP client.
One TypeScript codebase across web, mobile and API
Request and response types are defined once and consumed by a Next.js frontend, a React Native app and the service itself, so a contract change breaks the build instead of surfacing as a production runtime error. For a small product team that removes a whole category of coordination cost.
Serverless and spiky traffic
Node's start-up footprint is small enough that cold starts on Lambda or Cloud Run stay in the tens of milliseconds for a lean bundle, which makes per-request billing genuinely cheap for bursty workloads — scheduled imports, webhook receivers, seasonal peaks.

We would choose something else

CPU-bound work — media transcoding, large PDF or report generation, bulk cryptography, heavy data transforms
There is one event loop per process. Any synchronous block stalls every other in-flight request on that process, so a 300 ms JSON.parse of a large payload adds 300 ms of latency for every concurrent user, not just the one who asked for it. worker_threads exist, but they do not share ordinary objects — data crosses by structured clone or SharedArrayBuffer — so you end up hand-building a thread pool and a serialisation format that Go, Rust, or a Python worker with native libraries would have given you for free.
Money arithmetic and anything numerically strict
JavaScript numbers are IEEE-754 doubles. There is no native decimal type, so every ledger has to be integers in minor units or a decimal library applied with total discipline, and one stray floating-point path in a reconciliation job is a defect class rather than a bug. The 2^53 safe-integer ceiling is the same problem in different clothing: int64 identifiers from a Postgres bigint or a Java system silently lose precision unless the driver returns BigInt or strings, and that failure stays invisible until the IDs get large.
Organisations that cannot fund dependency governance
A modest API service commonly resolves to several hundred transitive packages, and by default install scripts execute arbitrary code on your build machine. Maintainer account takeovers, typosquats and protestware are a recurring, documented category of incident in this ecosystem. If a client cannot support a private registry proxy, committed lockfiles, lifecycle scripts off by default, and a real review gate before a package is added, we say so and recommend a runtime with a larger standard library instead of pretending the risk is theoretical.
Hard tail-latency budgets at p99.9
V8's garbage collector is generational and largely concurrent, but it still produces pause outliers, and a single-threaded loop means one slow handler pushes the tail for everyone queued behind it. Where the requirement is a tight, provable upper bound — an exchange matching path, a real-time bidding responder, a control loop — Go's scheduler or a non-GC language gives a much narrower latency distribution for the same effort.
Large in-process state or in-memory analytics
V8's old-space is capped and has to be set below the container memory limit, or the kernel OOM-kills the process with no stack trace before the GC can help. Holding multi-gigabyte caches or running analytical passes in-process therefore fights the runtime, and since Node scales horizontally by adding processes, none of that memory is shared between them. That state belongs in Redis, in the database, or in a query engine built for it.

In practice

What we build with Node.js

  • TypeScript REST and GraphQL services on Fastify or NestJS, with JSON Schema validation at the route boundary and that schema doubling as the published OpenAPI contract.
  • Real-time tiers over WebSocket and server-sent events — presence, live order and delivery status, notification fan-out, collaborative editing — with reconnect, backpressure, and scale-out across replicas through Redis pub/sub.
  • Integration and webhook layers: signature verification, idempotency keys, exponential backoff with jitter, dead-letter queues, and replay tooling for the day a provider sends six hours of events at once.
  • Background worker tiers on BullMQ, SQS or Redis Streams, with visibility timeouts, poison-message handling and job-level tracing, deployed separately from the request path so a slow job cannot starve an API.
  • Backend-for-frontend services between a Next.js or React Native client and a set of internal or legacy systems, so the client talks to one shaped API instead of five.

Positions

Decisions we have already made

Defaults we start from. They are arguable — but they are argued, not assumed, and we will change them for a good reason.

Fastify or NestJS on the current Node LTS — not Express by default
Express 5 has shipped, but it remains a maintenance release: still no built-in schema validation and no typed serialisation. Fastify validates input against JSON Schema per route and serialises responses through a compiled stringifier, which is both faster and safer: a field you did not declare cannot leak out of a response. We pick NestJS instead when the team is large enough that module boundaries and dependency injection earn their overhead. Either way we stay on an active LTS line with a scheduled upgrade rather than drifting onto an unsupported major.
SQL-first data access over a full ORM
We default to a typed query builder — Kysely or Drizzle — against PostgreSQL, because the hot paths in real systems need CTEs, window functions, partial indexes and SELECT … FOR UPDATE SKIP LOCKED, and lazy-loading ORMs hide the N+1 queries that cause the incident. Prisma is a fair pick where the schema is simple and migration ergonomics matter more than query control; we avoid it where the query plan is part of the design. Pool size is set explicitly, counted across every replica, and kept under the database's max_connections — an autoscaled Node fleet exhausts a database's connection limit long before it exhausts its CPU.
Containers on ECS Fargate or Cloud Run first; Lambda only for the right shape; Kubernetes only when the fleet justifies it
For a handful of services, a managed container runtime gives rolling deploys, health checks and autoscaling with no control plane to operate. We put Node on Lambda when traffic is spiky and requests are short and stateless, and keep it off Lambda when the service holds WebSocket connections, depends on a warm connection pool, or runs long jobs, because the execution model fights all three. Kubernetes enters when workload heterogeneity or multi-tenancy actually demands it — never because the service happens to be written in Node.
TypeScript in strict mode, ESM, and a dependency budget that is actually enforced
strict plus noUncheckedIndexedAccess, no implicit any, and runtime validation with Zod or JSON Schema at every trust boundary — types are a build-time tool and stop at the socket, so external input still gets parsed. On dependencies: lockfile committed, installs via npm ci from a private registry proxy, lifecycle scripts disabled by default with a reviewed allowlist, an SBOM produced in CI, updates through a bot with a human review gate rather than automerge, and a stated reason required before a package is added. Small utilities that wrap twenty lines of standard library get written, not installed.

Architecture

Things worth getting right early

The event loop is a shared resource, so we budget it
Every request handler has an implicit synchronous budget of a few milliseconds. Anything beyond that — parsing a large upload, rendering a document, hashing in bulk — moves to a worker_threads pool, a queue consumer, or a service in another runtime. We instrument event loop delay with perf_hooks.monitorEventLoopDelay and alert on its p99 alongside request latency, because loop lag rises before user-visible latency does and is the earliest honest signal that a process is saturated. The readiness probe reports unready once lag crosses a threshold, so the load balancer stops sending work to a process that cannot do it.
Failures are asynchronous, so failure handling has to be explicit
Every outbound call carries a timeout and an AbortController — most HTTP clients default to no timeout at all, which turns one slow upstream into a pile of stuck requests and an exhausted pool. Unhandled rejections terminate the process by design and we leave that behaviour in place, letting the orchestrator restart, rather than swallowing an error into unknown state. SIGTERM triggers a graceful drain: stop accepting connections, finish in-flight requests, close pools, then exit — without it, a routine deploy drops live requests and half-committed jobs.
Process model and memory are configured deliberately
We scale with container replicas — one lean process per allotted core — instead of the cluster module, so the orchestrator owns supervision, rolling restarts and placement rather than a parent process reimplementing them badly. --max-old-space-size is set below the container memory limit so V8 collects instead of being OOM-killed silently, and leak investigations run on heap snapshots rather than guesswork. Runtime images are slim or distroless, run non-root, and are built from the same lockfile CI resolved.
Observability is part of the service, not a later add-on
OpenTelemetry instrumentation covers HTTP, the database driver and the queue client from the first sprint, with trace context propagated across the API, the worker tier and downstream services, so a slow checkout is one trace instead of four log searches. Logs are structured JSON carrying a request id, and the RED metrics plus event loop lag are on a dashboard before the first production cutover, not after the first incident.

Around it

What Node.js usually sits next to

  • TypeScript

    We do not ship untyped Node. Strict TypeScript turns contract changes between services and layers into build failures instead of production surprises.

  • PostgreSQL

    The default datastore behind a Node API — transactional, relational, honest about concurrency. Pool sizing is the place where Node and Postgres have to be tuned together.

  • Redis

    Holds the state Node should not keep in-process: caches, rate limiters, idempotency keys, job queues, and pub/sub for scaling WebSockets across replicas.

  • WebSocket

    The transport where Node's connection model pays off most — long-lived, mostly idle sockets held cheaply inside one process.

  • Next.js

    Shares the runtime and the type definitions with the API, so one team moves across the frontend and the service boundary without a context switch.

  • OpenTelemetry

    Traces the asynchronous call graph that Node makes easy to create and hard to reconstruct from logs alone.

Questions

Common questions about Node.js

  • Node.js or Go for our backend?

    Ask what the service does between receiving a request and answering it. If it mostly waits on other systems — databases, payment providers, third-party APIs — Node is the faster build and the adapter ecosystem is deeper. If it computes, holds large state, or has to meet a strict p99.9 latency target, Go's real concurrency and tighter tail latency win and repay the extra typing effort. Many systems are best as both: a Node API tier in front of one or two Go services doing the heavy part.

  • Is Node.js fast enough for high traffic?

    For I/O-bound work, yes — throughput is usually capped by the database, the upstream provider, or your connection pool, not by V8. The failure mode is not raw speed; it is one blocking operation on the event loop degrading every concurrent request at once. That is why we treat event loop delay as a first-class metric, keep synchronous work inside a small budget, and load-test with the payload sizes production will actually see rather than a toy JSON object.

  • How do you handle the npm supply chain risk?

    Committed lockfiles and installs via npm ci from a private registry proxy, so nothing changes under you between builds. Lifecycle scripts are disabled by default with a reviewed allowlist, because postinstall is the most common execution path for a malicious package. CI runs an audit and produces an SBOM, updates arrive through a bot with a human review gate rather than automerge, and adding a dependency requires a stated reason — thin wrappers around standard library calls get written instead. If a client's environment cannot support that, we say plainly that Node is a risk they are choosing, and offer the alternative.

  • Can Node do CPU-heavy work at all?

    Sometimes, with care. worker_threads gives real parallelism for jobs like image resizing or PDF rendering, and native addons push the actual computation into C++ where it belongs. But data does not share across threads the way it does in Go or Java — it is copied, or passed through SharedArrayBuffer — so the boundary costs both performance and complexity. Our rule: if CPU work is incidental, use a worker pool; if CPU work is the point of the service, write that service in something else and call it over HTTP or gRPC.

  • We want AI features. Node or Python?

    Both, with a clear seam. Model training, evaluation and most inference libraries live in Python, and that is not worth fighting. Node is a good place for the product layer around them: request handling, auth, streaming responses to the browser token by token, prompt assembly, retries, and cost accounting. We keep the Python side behind an explicit HTTP or gRPC contract so each side can be deployed, scaled and replaced independently.

Services that use Node.js

Where it lands

Not sure Node is the right runtime for your service?

Send us what the system has to do — the traffic shape, the upstreams it talks to, the latency you have to hold — and we will tell you where Node fits, where it does not, and what we would put in its place. If the honest answer is Go, Python, or something already on your stack, that is the answer you will get. Small focused pieces of work start around Rs 25,000; larger platform builds are scoped after that conversation.