Node.js
Software Mile builds back-end services and APIs in Node.js – efficient for I/O-heavy and real-time workloads, and a natural fit when your front end is already JavaScript.
Where Node.js Fits
- REST and GraphQL APIs and back-end services
- Real-time systems with WebSockets and event-driven architecture
- Backends for React, React Native, and other JavaScript front ends – one language across the stack
- Serverless functions and cloud-native services
Node keeps the stack in one language and handles real-time and I/O-heavy work well. Need a dedicated developer? See hire a Node.js developer, or tell us your project.
What One Language Across the Stack Actually Saves
Some things, and they are more specific than the pitch suggests. Shared validation logic, shared type definitions between API and client when you use TypeScript, one package manager and one toolchain, and reviewers who can read both sides of a feature. On a small team those matter, because the same people carry every extra tool.
What does not follow is that any front-end developer becomes a back-end developer. Database design, transaction handling, authorization boundaries and failure behavior under load are separate skills that do not arrive with the language. If you are choosing Node so that one team covers both ends, be explicit about which of those skills the team has today and which it will have to learn on your project.
When Should You Not Choose Node.js?
Node handles many concurrent connections that spend most of their time waiting. It handles sustained computation badly, because work occupying the event loop delays every other request on that process. Image and video processing, large report generation and heavy data transformation sit in that category. Worker threads exist and are worth reaching for at the edges, but if most of your workload looks like that you are fighting the runtime.
There is also the question of what your organization already operates. Deployment pipelines, monitoring, log aggregation and security review get built up per platform, and a lone Node service arrives with none of that attached. Assembling it is not difficult, but it is work that seldom appears in the estimate, and it lands on the operations team, who did not choose the runtime.
TypeScript or Plain JavaScript for a New Service?
TypeScript costs a build step and a little ceremony at the edges. It repays that on anything a team maintains past a few months, and it repays most at API boundaries, where a mismatch between what the client sends and what the server expects is otherwise found in production. If more than one person will touch the code, use it.
For one small function with a short life, plain JavaScript with documented input shapes is defensible and quicker to ship. The failure mode to avoid is a large TypeScript codebase where everything important is typed as any, which costs the build step and buys nothing back.
Real-Time Is More Than Opening a Socket
Before committing to real-time, check what real-time means for your users. A dashboard that has to be current within a minute can poll a REST endpoint, and polling is far easier to reason about when something goes wrong. Sockets earn their complexity for collaborative editing, live position tracking and chat; a status page rarely needs them.
Where sockets are genuinely warranted, budget for more than the connection. Opening a WebSocket takes a few lines of code, which is why real-time gets estimated as a small feature. The work that consumes the schedule appears once real users are connected over real networks, and it is split between application code and infrastructure:
- Reconnection and state recovery when a client drops and comes back.
- Authorization on a long-lived connection, not only at the handshake.
- Horizontal scaling, which needs sticky routing or a shared publish and subscribe layer.
- Ordering and duplicate handling when messages arrive out of sequence.
- Backpressure, so one slow client does not consume server memory.
Dependency Sprawl Is a Standing Cost
A modest Node application can pull in hundreds of transitive packages, and every one of them is code you ship without reading. The controls are unglamorous: commit the lockfile, pin the runtime to a supported release line, prefer fewer well-maintained dependencies, and treat an unmaintained package as a defect with a date on it.
The judgment worth developing is when to add a package at all. One that saves an afternoon and brings twelve transitive packages with it is a poor trade; one that implements a protocol or a cryptographic primitive almost always is not. Teams that have never made that decision explicitly tend to accumulate the first kind, and the audit output eventually forces the conversation anyway.
Release Lines, Pinning, and What a Major Upgrade Actually Breaks
Node publishes a new major every six months. Even-numbered majors arrive in April and become Long Term Support that October, then get roughly thirty months from first release before they go end of life. Odd-numbered majors arrive in October and are supported for about six months; they exist to land breaking changes, not to carry production traffic. End of life means no security patches, not merely no new features, so a service that stays on a line past that date is running unpatched whether or not anyone has noticed.
What breaks on a major upgrade is usually not your JavaScript. Native addons compiled against V8's ABI carry a NODE_MODULE_VERSION and have to be rebuilt for each major, so a package with no prebuilt binary for the new version either compiles from source on the build machine or fails outright; addons written against Node-API are the exception, because that ABI is stable across majors. A major can also bring a new bundled OpenSSL, and OpenSSL 3 moved older algorithms into a legacy provider that is not loaded by default, so code that reads an old PKCS#12 bundle or asks for a retired cipher returns an unsupported-algorithm error rather than a wrong answer. The engines field in package.json records the intended range, but npm treats it as advisory unless engine-strict is set, so the version that actually runs is whatever the image or the host provides.
Upgrades also subtract work. fetch is a global from Node 18, a test runner ships as node:test, --watch restarts the process on file changes, and --env-file reads a .env file, all of which used to arrive as packages. When you move a release line, check whether something in the manifest is now redundant.
The Module Boundary: ESM, CommonJS, and Interop
Node runs two module systems in the same process tree. The type field in package.json decides how .js files are read, module for ESM and commonjs or nothing for CommonJS, while .mjs and .cjs are always one or the other regardless of that setting. ESM is resolved and evaluated asynchronously, which is why CommonJS could not require() an ES module for years and produced ERR_REQUIRE_ESM instead; newer release lines allow require() of an ES module graph that contains no top-level await, and dynamic import() remains the escape hatch that works from either side. The direction that always worked is ESM importing CommonJS, though named imports from a CommonJS file are recovered by static analysis of the source and fall back to a default-only import when exports are assigned in a loop or behind a condition.
The failure modes are specific and worth recognizing on sight. In ESM there is no __dirname, no __filename and no require; the replacements are import.meta.url with fileURLToPath, and import.meta.dirname on newer runtimes. Relative specifiers must carry their file extension, which is why TypeScript source targeting ESM has to import "./thing.js" for a file named thing.ts, and why leaving it off produces ERR_MODULE_NOT_FOUND only after the build. When a dependency adds an exports map to its package.json, deep imports into its internals start throwing ERR_PACKAGE_PATH_NOT_EXPORTED, and the repair is to move to the public entry point rather than to pin around it.
For a new service, choose one system and record it in package.json before the first dependency lands. Mixed graphs work, but the cost shows up in tooling configuration, where the test runner, the transpiler and the bundler each have to be told the same thing again, and in every developer who has to keep track of which half of the codebase they are editing.
One Thread per Process, and the Pool Behind It
A Node process runs your JavaScript on a single thread, so capacity beyond one core comes from running several processes: the cluster module, a process manager, or container replicas with something in front distributing connections. The consequence that gets missed is that anything held in a variable is per-process. An in-memory cache is warmed separately in each one and can hold different answers, a rate limiter counts to its limit once per process, and a scheduler started inside the application fires once per process rather than once per deployment. Each of those has to move to shared storage, a lock, or an external scheduler, and until it does the symptom is intermittent behavior that depends on which process happened to take the request.
Behind the single thread, libuv keeps a small pool of worker threads that a few standard library operations depend on: file system calls, zlib compression, some crypto functions such as pbkdf2 and scrypt, and dns.lookup, which is what a hostname in an outbound HTTP request resolves through unless the code calls dns.resolve directly. The pool defaults to four threads. Saturating it produces latency on exactly those operations while CPU stays low and the event loop shows no delay, so one slow DNS server can occupy every slot while the process looks idle. UV_THREADPOOL_SIZE is the adjustment.
Telling Apart the Three Reasons a Node Service Is Slow
Slow requests in a Node service almost always trace to one of three causes: the event loop is blocked, the service is waiting on something downstream, or requests are queueing for a limited resource. On a latency chart the three are indistinguishable. What separates them is which signals move together, so the triage is to read process CPU, event loop delay, and the service's own recorded timings for its outbound calls at the same moment rather than one at a time.
- Blocked event loop. CPU on the process is high, event loop delay measured with perf_hooks monitorEventLoopDelay climbs into the tens or hundreds of milliseconds, and every endpoint slows together, including ones that touch nothing, such as a health check. Get the work off the thread; adding replicas raises total throughput but leaves every request queued behind the blocking one on its own process.
- Waiting downstream. CPU is low, event loop delay is flat, and your latency tracks a database query or an upstream API almost exactly. The fix belongs to that query or that service, and adding replicas puts more load on whatever is already slow.
- Queueing for a resource. Time is spent before the work starts, visible as connection pool acquire time, threadpool waits, or an outbound agent sitting at its socket limit, while the downstream reports normal timings and latency rises in steps as concurrency grows. Raise the limit or lower concurrency, and count the pool across every process and replica against the database server's own maximum.
Timeouts, Keep-Alive, and Stopping Without Dropping Requests
Node's HTTP server ships defaults chosen for a server facing the open internet, not one sitting behind a proxy. server.keepAliveTimeout is five seconds; if the load balancer's idle timeout is longer, the proxy will eventually reuse a connection at the moment Node is closing it, and the result is a low, steady rate of 502s that no application log explains. Set keepAliveTimeout above the proxy's idle timeout and keep headersTimeout above keepAliveTimeout, and treat the two as one setting with two numbers. server.requestTimeout defaults to five minutes, which is a ceiling rather than a service level.
Outbound, the defaults run the other way. A request made with fetch has no overall deadline, so a call to a service that accepts the connection and then stalls can outlive the request budget of the caller waiting on you. AbortSignal.timeout() is how you impose one, and the value has to be smaller than the timeout your own caller is holding, or the deadline is decorative.
Shutdown is the part that gets written last and tested least. On SIGTERM, server.close() stops accepting new connections but leaves idle keep-alive connections open until they expire on their own, which is what closeIdleConnections() is for, and an orchestrator that follows SIGTERM with SIGKILL after a grace period will cut off anything still running. The ordering that avoids dropped requests is to fail the readiness probe first, allow the load balancer time to stop routing, then close the server and let in-flight work finish. Note also that since Node 15 an unhandled promise rejection terminates the process by default, so a missing catch is a restart and the supervisor's restart policy is part of the design.
Frequently Asked Questions
What is Node.js, and what is it not?
Node.js is a runtime: the V8 engine executing JavaScript outside a browser, libuv providing the event loop and non-blocking I/O, and a standard library covering files, sockets, HTTP, streams, crypto and child processes. It is not a framework, so routing, validation, data access, background jobs and dependency injection come from packages or from code you write. It is not an application server either; there is no container hosting your code the way a JVM application server or IIS does, and the process your code starts is the server. The language is JavaScript, and TypeScript is a build-time layer over it rather than a separate runtime.
What has to exist before a Node service can run in our environment?
A supported runtime version on the host or in the container image, a supervisor or orchestrator that restarts the process when it exits, TLS termination and routing to the port it listens on, a way to supply configuration and secrets, somewhere for logs to go, and network reach plus credentials to the databases and services it calls. Logs deserve specific attention, because Node writes to stdout and stderr and has no built-in log file or rotation; something else has to collect and retain the output. Existing platform rules then have to be extended to cover the new runtime: the base image policy needs an approved Node image, dependency scanning has to read package-lock.json, and log parsing has to handle the format the service emits. Naming an owner for each of those items is the difference between doing that work on a schedule and discovering it at cutover.
Do we have to change our front end to use a Node back end?
No. A Node service exposes HTTP and JSON like any other back end, so an existing front end in React, Angular, a server-rendered template or a native mobile app talks to it unchanged. What is worth settling early is the contract: an OpenAPI document or a GraphQL schema that both sides build against, and who updates it when shapes change. If the service answers from a different origin than the page, CORS, cookie SameSite settings and credentialed requests have to be decided at the same time, because those are what turn into an unexplained browser error late in integration.
Can a Node service use the same database as our existing services?
Yes. Maintained drivers exist for PostgreSQL, MySQL, SQL Server, Oracle and the common document stores, and a Node service is simply another client. The number to check is connections: each process keeps its own pool, so the total is pool size times processes times replicas, and that product is what counts against the database server's connection limit, which is often the point where a connection pooler becomes necessary. Where two services write the same tables, decide which one owns the schema and the migrations before either ships, because shared ownership tends to fail during a deployment rather than in testing.
Should this run as serverless functions or as a long-lived service?
Bursty, short, stateless work fits functions well; steady traffic and anything holding a connection open does not. A function instance handles one request at a time, so the in-process caching and connection pooling a long-running Node process relies on either does not apply or needs a pooler in front of the database, and instance count multiplies connections instead of sharing them. Functions also carry an execution time limit, and WebSockets need a separate managed service rather than a plain function, because there is no process left to keep the socket attached to. Cold start is real, though for Node it is usually dominated by the import time of a large dependency tree, which is the part you control.
Does Node run the same on every operating system and base image?
Your own JavaScript does. Node runs on Linux, Windows and macOS, and the standard library behaves consistently apart from path handling and file permissions. Native addons are where environments diverge: a package that ships prebuilt binaries builds them for a specific platform and C library, so moving from a Debian-based image to Alpine, which uses musl rather than glibc, can turn an install that worked into a source compile that needs a toolchain, or into an outright failure. If a minimal image is the target, run the install on it early rather than during hardening.
What do we have to provide or decide before development starts?
The release line to target, TypeScript or plain JavaScript, and where the service will run, since a container, a VM and a function imply different configuration, logging and scaling behavior. Access is the other half: the repository, the package registry to install from (public npm or an internal mirror, which changes how a lockfile resolves), a non-production environment, and credentials for the systems the service integrates with. You also have to decide who owns dependency updates after handover, because a Node dependency tree keeps moving whether or not anyone is assigned to watch it.