Building Microservices with Node.js: What Actually Matters in Production

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…

Microservices Using Node.js
guide.md READY

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.

SituationWhat I Would Consider
New product with a small teamModular monolith
Simple CRUD applicationMonolith
Clear independent business domainsPossibly microservices
One workload needs very different scalingMicroservice can make sense
Several teams need independent releasesMicroservices become more attractive
Limited DevOps and observability capabilityStay 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:

Share this guideLinkedInPost

ARTICLE TOOLKIT

Save or share this guide

Keep the reference nearby or send it to a teammate solving the same problem.

Share this guideLinkedInPost

QUALITY NOTE

Written from practical development experience and reviewed for clarity. Found an outdated step?

Report a correction →

Jaydip Barad

WRITTEN BY

Jaydip Barad

Senior full-stack developer sharing production-tested lessons from 14+ years of building backend systems, WordPress platforms and modern JavaScript applications.

Node.jsTypeScriptWordPressArchitecture
Previous guide
Next guide

THE PRACTICAL DEVELOPER LETTER

Get useful engineering lessons without the noise.

New tutorials, architecture notes and tools worth knowing—delivered occasionally.




    Occasional practical tutorials. Unsubscribe any time.