Node.js is a natural fit for microservices. Fast cold starts, small memory footprint, strong async story, and an HTTP-first runtime make it easy to spin up services that do one thing well. That's also why so many teams end up with a fleet of Node services, some owned by different teams, some copy-pasted from a template, some behind an API gateway, some not, and a nagging sense that the system got away from them.
This guide covers what actually works when you build microservices in Node.js: where service boundaries should fall, how services should talk to each other, where the data lives, how to deploy, and the failure modes that turn a microservice system into a distributed monolith. We also introduce a framework at the end that handles a lot of the scaffolding for you.
Before anything else: microservices are an organizational tool with performance side effects. They're not a default architecture.
Stay with a monolith if:
Consider microservices when:
Most "we should split this into microservices" conversations are really "we should clean up our module boundaries." Splitting a well-factored monolith later is cheap. Merging a poorly-factored microservice mesh is not.
The first and hardest problem. Bad boundaries create services that gossip constantly, own slivers of each other's data, and have to deploy together. That's a distributed monolith.
Good boundaries follow domain lines, not technical layers:
auth-service, database-service, logging-service, api-service. Technical layers pretending to be domains.users, orders, catalog, payments, shipping. Each owns its data, its business logic, and its read/write API.A useful smoke test: if "add a field to this entity" requires changing three services, your boundaries are wrong.
Start with fewer, larger services. You can always split later. Going the other direction (merging services that shouldn't have been split) is far more painful.
Three patterns, different tradeoffs.
Service A calls Service B over HTTP. Simple. Familiar.
// Service A calling Service B
const resp = await fetch("http://users-service/users/" + id);
const user = await resp.json();
Advantages: easy to reason about, easy to test.
Tradeoffs: tight coupling on availability (B goes down, A fails), latency compounds across hops, cascading failures are easy.
Use synchronous calls for reads that genuinely need a fresh answer. Avoid them for anything that can happen asynchronously.
Service A publishes an event. Any number of services consume it.
// Service A
await queue.publish("order.created", { orderId, userId });
// Service B (inventory)
queue.subscribe("order.created", async (event) => {
await reserveStock(event.orderId);
});
Advantages: loose coupling, services can be down without breaking the producer, easy to add new consumers.
Tradeoffs: eventual consistency (responses are "it happened, eventually"), harder to debug than synchronous calls, requires a message broker.
Use events for workflows that span services, "order placed" → "reserve inventory" → "charge payment" → "send email".
A single entry point that routes to backend services. Handles auth, rate limiting, response aggregation.
Client → Gateway → { users-service, orders-service, catalog-service }
Advantages: clients don't see the microservice fragmentation, central place for cross-cutting concerns.
Tradeoffs: the gateway becomes a critical path (single point of failure, deploy bottleneck), aggregations are network-heavy.
Use a gateway as soon as you have more than a few services exposed to clients. Don't let every service be publicly addressable.
If you go async, you need a broker. The common choices:
For most Node.js microservice systems, SQS + SNS (on AWS) or GCP Pub/Sub is the pragmatic pick, no broker to operate, pay-per-use, durable by default.
Don't let services call each other without authentication. A leaked internal service URL is an incident. Options:
For small Node.js fleets, internal JWTs with a KMS-managed key are often enough. For large fleets, a service mesh becomes worth it.
Core rule: each service owns its database. No other service reads or writes it directly. Cross-service queries happen through APIs or events.
If Service A and Service B share a database, you don't have microservices, you have a distributed deployment of a shared schema. You get the ops cost without the isolation benefit.
Typical setup per service:
This is where small teams give up, running five Postgres instances sounds heavy. Managed services (AWS RDS, GCP Cloud SQL, Neon, Supabase, Railway) make it survivable. Skip this discipline and you'll regret it.
Each service deploys independently. In practice:
A common mistake: monorepo with shared CI that rebuilds and redeploys everything on every change. This kills one of microservices' main benefits (independent deploy). Use build caching and path-based triggers.
Non-negotiable. Without it, debugging a multi-service request is archaeology.
Three things you need:
Setting all this up from scratch for a 5-service Node fleet is a week of work. Doing it right once and templating it for new services saves that week every time.
The ugly truth of microservices: local dev is hard. Five Node services, a database per service, a broker, maybe an API gateway, that's a lot to run on a laptop.
Options, in order of complexity:
docker-compose up. Fine for small fleets, slow as they grow.The right answer depends on team size. A three-person team can live with Docker Compose. A thirty-person team can't.
Failures that reliably happen in Node.js microservice systems:
opossum is a Node circuit breaker library) and backpressure.Most of this guide is "here's the problem, here's a pattern, here's what to wire up." A lot of the wiring is the same across every Node microservice system: Pub/Sub setup, trace context propagation, structured logs, service-to-service auth, per-service DB provisioning. Doing it from scratch for each new project is rebuild-the-template work.
Encore is a framework that handles that scaffolding. You declare services, APIs, databases, and Pub/Sub topics as typed objects in TypeScript, and Encore generates the infrastructure, running locally for dev, provisioned on AWS or GCP for prod.
// users/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("users");
// users/users.ts
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
const db = new SQLDatabase("users", { migrations: "./migrations" });
export interface User { id: number; email: string; }
export const get = api(
{ method: "GET", path: "/users/:id", expose: true },
async ({ id }: { id: number }): Promise<User> => {
return await db.queryRow`SELECT * FROM users WHERE id = ${id}`;
},
);
// orders/orders.ts
import { users } from "~encore/clients";
export const createOrder = api(
{ method: "POST", path: "/orders", expose: true },
async (req: CreateOrder) => {
const user = await users.get({ id: req.userId }); // compile-time checked
// ...
},
);
Cross-service calls are type-checked at compile time, no silent breakage when a field renames.
// orders/events.ts
import { Topic } from "encore.dev/pubsub";
export const orderCreated = new Topic<OrderCreated>("order-created", {
deliveryGuarantee: "at-least-once",
});
// inventory/subscriptions.ts
import { Subscription } from "encore.dev/pubsub";
import { orderCreated } from "~encore/clients/orders";
new Subscription(orderCreated, "reserve-stock", {
handler: async (event) => { /* ... */ },
});
Encore picks SNS+SQS on AWS or Pub/Sub on GCP. You don't configure the broker.
encore run starts every service, database, and queue locally.For a new Node.js microservice project, this replaces several weeks of template-building. Encore is open source (11k+ GitHub stars) and runs in production at companies including Groupon.
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.
If you're building microservices from scratch in Node.js, pick one of these paths:
# DIY Express/Fastify + Docker Compose
mkdir -p services/users services/orders
# ... build templates, wire up broker, logging, tracing
# NestJS microservices
npm i -g @nestjs/cli && nest new project
# Encore
brew install encoredev/tap/encore
encore app create my-app --example=ts/empty
cd my-app && encore run
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.