Microservices are easy to explain on a whiteboard.
You draw a few boxes, connect them with arrows, give every box a database, and suddenly the application looks clean and scalable.
Production is where the interesting part begins.
One service becomes slow. Another service is unavailable. The same message is delivered twice. A database update succeeds but the event that should follow it never gets published. Then someone has to figure out what happened across five different log streams.
That is why, when I think about Node.js microservices, I don’t start with Docker, Kubernetes, or even the framework.
I start with a simpler question:
Does this part of the application actually need to become a separate service?
In this guide, I’ll build a small Node.js microservices example, but I also want to cover the parts that basic tutorials usually skip: service boundaries, failure handling, synchronous versus asynchronous communication, message duplication, data ownership, tracing, and deployment.
What Microservices Really Change
A microservice is an independently deployable application responsible for a specific business capability.
For an e-commerce system, possible boundaries might be:
- Customers
- Orders
- Payments
- Inventory
- Shipping
- Notifications
The important word here is business.
I wouldn’t create separate services called database-service, validation-service, and controller-service. Those are technical layers, not useful service boundaries.
A better boundary is something that can clearly own its behaviour and its data.
Order Service
|
+-- order rules
+-- order API
+-- order events
+-- order database
Once services are separated, however, operations that used to be simple function calls become network calls.
// Before
const customer = customerService.getCustomer(id);
// After
const response = await fetch(
`http://customer-service/customers/${id}`
);
That one architectural decision introduces latency, timeouts, retries, partial failures, authentication between services, monitoring, and network-related errors.
Microservices give us independence, but that independence has a cost.
When I Would Not Use Microservices
I would not automatically start a new application as a collection of microservices.
For a new product with a small team, a well-structured modular monolith is often easier to build, debug, deploy and change.
A monolith gives us several useful things without additional infrastructure:
- one deployment
- simple local development
- normal database transactions
- easier debugging
- fewer network calls
- less monitoring infrastructure
I start considering microservices when there is an actual reason for an independent boundary.
For example, one area may need to scale very differently from the rest of the system. Different teams may need independent releases. A particular workload may need its own reliability requirements, infrastructure or deployment cycle.
If those problems don’t exist yet, splitting the application into ten services usually creates more work rather than less.
Why Node.js Works Well for Microservices
Node.js is a natural fit for many microservice workloads because backend services spend a lot of time waiting for I/O.
- database queries
- HTTP APIs
- Redis
- message brokers
- object storage
- third-party services
Node’s asynchronous I/O model works well for this kind of service.
For production, I would always use an actively supported LTS release instead of copying an old Docker image from a tutorial. At the time I’m updating this guide, Node.js 24 is an LTS release. You can always verify the current status on the official Node.js release page.
The Architecture We’ll Build
I’ll keep the example small enough to understand, but realistic enough to discuss production concerns.
Client
|
v
Order Service
|
+------ HTTP ------> Customer Service
|
+------ Event -----> RabbitMQ
|
v
Notification Service
The responsibilities are deliberately simple:
- Customer Service owns customer information.
- Order Service creates and manages orders.
- Notification Service reacts to events and sends notifications.
This architecture also gives us two different communication patterns to discuss: synchronous HTTP and asynchronous messaging.
Choosing the Node.js Framework
The microservices architecture is more important than whether the HTTP layer uses Express, Fastify, NestJS or Hono.
For this example I’m using Fastify with TypeScript. It keeps the example small while still giving us a solid HTTP foundation.
Fastify’s current documentation is available on the official Fastify website.
If you’re comparing framework styles for a real project, I have also written a detailed NestJS vs Hono comparison.
1. Build the Customer Service
Let’s start with the Customer Service.
mkdir customer-service
cd customer-service
npm init -y
npm install fastify
npm install -D typescript tsx @types/node
Create src/server.ts.
import Fastify from 'fastify';
const app = Fastify({
logger: true
});
const customers = [
{
id: 'customer-1',
name: 'Jay',
email: 'jay@example.com'
}
];
app.get('/customers/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const customer = customers.find(
item => item.id === id
);
if (!customer) {
return reply.code(404).send({
code: 'CUSTOMER_NOT_FOUND',
message: 'Customer not found'
});
}
return customer;
});
async function start() {
try {
await app.listen({
port: 3001,
host: '0.0.0.0'
});
} catch (error) {
app.log.error(error);
process.exit(1);
}
}
start();
I’m intentionally using in-memory data here. I don’t want database setup to hide the architecture we’re trying to understand.
In a real service, this would normally be backed by PostgreSQL, MySQL, MongoDB or another datastore appropriate to that service.
2. Build the Order Service
Now create the Order Service.
mkdir order-service
cd order-service
npm init -y
npm install fastify
npm install -D typescript tsx @types/node
Create src/server.ts.
import Fastify from 'fastify';
import { randomUUID } from 'node:crypto';
const app = Fastify({
logger: true
});
type CreateOrderBody = {
customerId: string;
productId: string;
quantity: number;
};
app.post('/orders', async (request, reply) => {
const body = request.body as CreateOrderBody;
if (
!body.customerId ||
!body.productId ||
!Number.isInteger(body.quantity) ||
body.quantity < 1
) {
return reply.code(400).send({
code: 'INVALID_ORDER',
message: 'Invalid order data'
});
}
const order = {
id: randomUUID(),
customerId: body.customerId,
productId: body.productId,
quantity: body.quantity,
status: 'created'
};
return reply.code(201).send(order);
});
async function start() {
try {
await app.listen({
port: 3002,
host: '0.0.0.0'
});
} catch (error) {
app.log.error(error);
process.exit(1);
}
}
start();
3. Calling One Microservice from Another
Before creating an order, suppose we need to confirm that the customer exists.
For something that requires an immediate answer, an HTTP call can be perfectly reasonable.
async function getCustomer(customerId: string) {
const response = await fetch(
`http://customer-service:3001/customers/${customerId}`
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(
`Customer Service returned ${response.status}`
);
}
return response.json();
}
One small change from older Node.js tutorials: I don’t automatically install Axios just to make a straightforward HTTP request. Modern Node.js versions provide fetch() directly.
But the interesting question isn’t which HTTP client we use.
The interesting question is: what happens when Customer Service doesn’t respond?
Always Put a Limit on Network Calls
A service-to-service request should not be allowed to wait forever.
If Customer Service starts taking 30 seconds to respond, Order Service can quickly end up with a growing number of requests waiting on the same dependency.
I prefer explicit timeouts.
async function getCustomer(customerId: string) {
const response = await fetch(
`http://customer-service:3001/customers/${customerId}`,
{
signal: AbortSignal.timeout(2000)
}
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(
`Customer Service returned ${response.status}`
);
}
return response.json();
}
Two seconds is only an example. The correct timeout depends on the operation and your system’s latency expectations.
The principle matters more than the exact number: a dependency should have a defined failure boundary.
This is also where centralized error handling becomes important. I cover that separately in my Node.js error handling guide.
Should Everything Be an HTTP Request?
No.
This is one of the first things I look at when services start becoming too tightly connected.
Imagine the Order Service does this:
Create order
|
v
Call Notification Service
|
v
Send email
|
v
Wait for email provider
|
v
Return API response
The customer doesn’t normally need to wait for the email provider before we confirm that the order was created.
That’s a good candidate for asynchronous processing.
Create order
|
+--- save order
|
+--- publish order.created
|
+--- return response
RabbitMQ
|
v
Notification Service
Now Order Service knows that an order was created. It doesn’t need to know how an email is built or which email provider is being used.
4. Adding RabbitMQ
RabbitMQ is one option for handling communication that doesn’t need to happen inside the HTTP request.
Install the Node.js AMQP client:
npm install amqplib
npm install -D @types/amqplib
After creating an order, we can publish an event.
const event = {
eventId: randomUUID(),
type: 'order.created',
orderId: order.id,
customerId: order.customerId,
createdAt: new Date().toISOString()
};
channel.sendToQueue(
'order.created',
Buffer.from(JSON.stringify(event)),
{
persistent: true
}
);
The Notification Service can consume the message independently.
channel.consume(
'order.created',
async message => {
if (!message) {
return;
}
const event = JSON.parse(
message.content.toString()
);
try {
await sendOrderConfirmation(event);
channel.ack(message);
} catch (error) {
console.error(
'Failed to process order.created',
error
);
channel.nack(message, false, true);
}
}
);
RabbitMQ’s own documentation describes work queues as a way to move time-consuming work out of the immediate execution path and distribute it between workers. The official JavaScript work queue tutorial is a good place to understand the fundamentals.
Queue and Publish/Subscribe Are Not the Same Thing
This distinction becomes important surprisingly quickly.
If I have three notification workers consuming the same work queue, I normally want one of those workers to handle each task.
order.created queue
|
+----+----+
| | |
v v v
W1 W2 W3
One worker handles each message.
But what if several different services should react to the same business event?
order.created
|
+----> Notification Service
|
+----> Analytics Service
|
+----> Loyalty Service
That’s closer to publish/subscribe.
RabbitMQ supports this model too. Its publish/subscribe tutorial shows the difference clearly.
Each Service Should Own Its Data
This is a rule I consider more important than whether the services live in separate repositories.
A design like this looks like microservices:
Customer Service -----+
|
Order Service --------+---- shared_database
|
Payment Service ------+
But the services are still tightly coupled through the database.
If Order Service starts reading Customer Service’s tables directly, a database change in Customer Service can break Order Service without any API contract changing.
I prefer explicit ownership:
Customer Service
|
v
Customer data
Order Service
|
v
Order data
Payment Service
|
v
Payment data
This does not mean you must immediately provision a completely separate physical database server for every service.
The important part is that one service owns the data and other services don’t casually modify its tables behind its back.
The Distributed Transaction Problem
This is where microservices start feeling very different from a normal application.
Inside one application and one database, an order workflow might be wrapped in a transaction.
BEGIN
create order
reserve inventory
create payment
COMMIT
If something fails, we can roll the transaction back.
Now move those responsibilities into separate services with separate databases.
Order Service
|
v
Inventory Service
|
v
Payment Service
What happens when the inventory reservation succeeds but the payment fails?
You need a business-level recovery path.
For example, Payment Service may publish a payment.failed event and Inventory Service may react by releasing the reservation.
This type of compensating workflow is often described as a saga.
The important thing isn’t memorising the pattern name. It’s recognising that once a transaction crosses service boundaries, partial success becomes a normal state that the application must handle.
Design Consumers for Duplicate Messages
Another production issue is message redelivery.
Imagine Notification Service successfully sends an email but crashes before acknowledging the RabbitMQ message.
The broker can deliver that message again.
If the consumer blindly executes the same action, the customer may receive the same notification twice.
That’s why I like events to carry a unique ID.
{
"eventId": "26e7cc82-...",
"type": "order.created",
"orderId": "order-7812",
"createdAt": "2026-08-07T09:30:00Z"
}
The consumer can check whether that event has already been processed.
if (await hasProcessed(event.eventId)) {
channel.ack(message);
return;
}
await handleOrderCreated(event);
await markAsProcessed(event.eventId);
channel.ack(message);
This idea is called idempotency: processing the same request or event more than once should not accidentally produce multiple business effects.
Don’t Ignore the Database-to-Broker Gap
There is another failure case that is easy to miss in tutorials.
Suppose Order Service performs these two steps:
1. Save the order
2. Publish order.created
The database write succeeds.
Then the process crashes before the event reaches RabbitMQ.
Now the order exists, but every system waiting for order.created knows nothing about it.
Publishing first isn’t a safe fix either because the event could be consumed even if saving the order later fails.
For workflows where this consistency matters, one pattern I would consider is the transactional outbox.
Database transaction
|
+--- save order
|
+--- save outbox record
|
v
Outbox worker
|
v
RabbitMQ
The order and the outgoing event record are stored in the same database transaction. A separate process publishes the pending outbox records to the broker.
It adds moving parts, but it solves a very real consistency problem.
Be Careful with Retries
Retries are useful, but I’ve seen retry logic discussed as if more retries automatically mean more reliability.
They don’t.
If another service returns a temporary 503 Service Unavailable, retrying after a short backoff may make sense.
If it returns 400 Bad Request because our payload is invalid, sending exactly the same request five more times isn’t helping anyone.
I prefer retries that are:
- limited
- used only for retryable failures
- combined with backoff
- visible in logs and metrics
Otherwise one failing dependency can cause every upstream service to hammer it with additional requests precisely when it is least capable of handling them.
Observability Is Part of the Design
With a monolith, one request may produce one useful stack trace.
With microservices, the same user action might travel through several processes.
Browser
|
v
API Gateway
|
v
Order Service
|
v
Customer Service
and later...
RabbitMQ
|
v
Notification Service
If I can’t connect those operations together, debugging becomes painful very quickly.
At minimum, I want a request or correlation ID included in structured logs.
{
"level": "info",
"requestId": "req-8a719",
"service": "order-service",
"orderId": "order-7812",
"message": "Order created"
}
When the architecture becomes larger, distributed tracing becomes even more useful. OpenTelemetry provides an open standard for traces, metrics and logs across distributed applications.
What I Monitor in Production
I don’t consider a service healthy just because its container is running.
For a microservices environment, some of the signals I care about are:
- request latency
- HTTP error rates
- database latency
- dependency latency
- message queue depth
- failed message processing
- retry counts
- dead-letter messages
- CPU and memory
Queue depth is especially useful.
A notification service can technically be “up” while processing 50 messages per second against an incoming rate of 500 messages per second.
The service isn’t down, but the backlog tells you that something is wrong.
Containerizing a Node.js Microservice
The previous version of this article used Node.js 14 in its Docker examples. I wouldn’t use an end-of-life Node release for a new production deployment.
A simple multi-stage build can look like this:
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3001
CMD ["node", "dist/server.js"]
The first stage installs development dependencies and compiles TypeScript.
The final stage contains only what the application needs to run.
Local Development with Docker Compose
For local development, Docker Compose makes it convenient to run our services and RabbitMQ together.
services:
customer-service:
build: ./customer-service
ports:
- "3001:3001"
order-service:
build: ./order-service
ports:
- "3002:3002"
depends_on:
- customer-service
- rabbitmq
notification-service:
build: ./notification-service
depends_on:
- rabbitmq
rabbitmq:
image: rabbitmq:4-management
ports:
- "5672:5672"
- "15672:15672"
One detail worth remembering: depends_on controls container startup ordering. It should not replace proper readiness checks, retries and failure handling inside the application.
Where an API Gateway Fits
If clients eventually need to communicate with many services, exposing every service directly can become awkward.
Web / Mobile
|
v
API Gateway
|
+----> Customer Service
|
+----> Order Service
|
+----> Payment Service
A gateway can provide a common place for concerns such as routing, authentication, rate limiting and request IDs.
What I try not to do is move every business rule into the gateway. If all the important application logic ends up there, we’ve simply created another tightly coupled application in front of our services.
Microservices Security Needs Its Own Attention
Moving functionality into separate services creates more communication boundaries, so security needs to be considered between services as well as at the public API.
I would think about:
- user authentication
- service-to-service authentication
- authorization
- input validation
- secret management
- rate limiting
- network access
- audit logging
I also don’t assume a request is trustworthy just because it came from an internal service.
My Node.js Microservices Production Checklist
Before I would be comfortable calling a service production-ready, I would want answers for most of these:
- Does the service have one clear area of ownership?
- Does it validate incoming data?
- Are outgoing network calls protected by timeouts?
- Are retries limited and intentional?
- Are errors logged consistently?
- Can requests be traced across services?
- Does it expose useful health information?
- Does it shut down gracefully?
- Does the service own its data?
- Are database migrations handled safely?
- Are message consumers idempotent?
- Are acknowledgements handled correctly?
- Is there a plan for repeatedly failing messages?
- Are metrics and alerts available?
- Are secrets stored outside the source code?
Microservices or a Modular Monolith?
If I were choosing an architecture today, this would be my rough starting point.
| Situation | What I Would Consider |
|---|---|
| New product with a small team | Modular monolith |
| Simple CRUD application | Monolith |
| Clear independent business domains | Possibly microservices |
| One workload needs very different scaling | Microservice can make sense |
| Several teams need independent releases | Microservices become more attractive |
| Limited DevOps and observability capability | Stay simpler until there is a strong reason to split |
There isn’t a universal winner.
The architecture should match the problem the team actually has.
Final Thoughts
Creating another Node.js HTTP server is easy.
Building a reliable distributed system is not.
Once an application is split into services, I start asking different questions:
- What happens if this dependency is down?
- Who owns this data?
- Can this event arrive twice?
- What happens if only half of this workflow succeeds?
- How will I trace this request when something fails?
Those questions matter much more than whether the first demo has two services or twenty.
My preference is to start with a simple architecture, keep business boundaries clean, and extract a microservice when there is a concrete reason to give that part of the system independent ownership, scaling or deployment.
Microservices should solve a problem you already understand. They shouldn’t become a new problem simply because the architecture looks good on a diagram.
Continue Learning
If you’re building production Node.js applications, these guides continue from the same practical approach:




