Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /Node.js Microservices Without Express: A Zero-Dependency Guide
nodejs19 min read

Node.js Microservices Without Express: A Zero-Dependency Guide

Build two real Node.js microservices with zero npm installs, using node:http, fetch, and node:test. No Express, no Docker required.

Zeeshan Tofiq
Zeeshan Tofiq
September 11, 2026
On this page

On this page

  • Why Skip the Usual Stack
  • What Actually Makes Something a Microservice
  • The Approach: Two Services, Zero Dependencies
  • Building Both Services Step by Step
  • The Challenges You Hit Immediately
  • Why Starting Here Beats Starting With the Full Stack
  • When to Actually Add Express, Docker, or a Queue
  • Frequently Asked Questions

Why Skip the Usual Stack

Open ten "Node.js microservices" tutorials and nine of them start the same way: install Express, install a HTTP client, write a docker-compose.yml, and stand up a message broker. By the time application code appears, you have installed six things and still do not know whether microservices are the right call for your project.

Node ships with everything two communicating services need. node:http is the server. Global fetch is the client. node:test is the test runner. --watch is the reloader. None of it needs an install step.

This is not an argument against Express or Docker forever. It is an argument for understanding what a microservice actually is before reaching for the production stack. By the end you will have two services talking to each other, and you will know exactly which problems the heavier tools solve, because you will have hit those problems yourself.

💡 TL;DR

Two Node processes, two package.json files with zero dependencies, and one fetch call between them is a real microservice setup. Use node --watch instead of nodemon and node --test instead of Jest. Add Express when a service passes roughly five routes, Docker when a second developer joins, and a queue when a call no longer needs a response.

Folder structure diagram showing a project root containing user-service and order-service as sibling directories, each with its own package.json, index.js, and index.test.js file, with a label noting that neither package.json has a dependencies field
Two sibling folders, two package.json files, zero dependencies between them and zero dependencies inside them.

Everything here runs on Node 22 or later with no flags. If you are on Node 18 or 20, --watch and the built-in test runner exist but some of their behaviour is still marked experimental, so upgrade before following along.

What Actually Makes Something a Microservice

A microservice is a small service that owns one piece of business logic and can be deployed on its own. It does not need Docker or Kubernetes to qualify. A Node process listening on a port, doing one job, and talking to other processes over HTTP already counts.

Three properties actually matter:

  • Independent deployability — you can redeploy the order service without touching, restarting, or rebuilding the user service.
  • Single responsibility — the user service knows about users. It does not know what an order is.
  • Owning its own data — each service controls its own storage. Two services reading and writing the same table directly is a monolith with extra network hops.

Everything else (service meshes, API gateways, orchestrators) is tooling that helps you run many services at scale. None of it is required for your first two. If you have ever prepared for a backend interview you have probably met these definitions before; our Node.js interview questions guide covers the same ground from the interview angle.

The third property is the one people get wrong. Splitting a codebase into two folders while both still connect to the same Postgres schema gets you all the operational cost of distributed systems and none of the benefit. If you take one rule from this article, take that one.

The Approach: Two Services, Zero Dependencies

We are building a small e-commerce style setup: a user service that stores user records, and an order service that creates orders and has to confirm the user exists first. This is the same core split behind most real commerce backends, just small enough to run on a laptop with node index.js twice.

text — project structure
project/
  user-service/
    package.json
    index.js
    index.test.js
  order-service/
    package.json
    index.js
    index.test.js

Two separate package.json files on purpose. Doing this for real, each folder becomes its own git repository or its own package in an npm workspace. For now, sibling folders keep it easy to open a terminal tab in each.

Neither has a single dependency. Here is the entire package.json:

json — user-service/package.json
{
  "name": "user-service",
  "type": "module",
  "scripts": {
    "dev": "node --watch index.js",
    "test": "node --test"
  }
}

That is the whole file. No dependencies key at all, so npm install has nothing to do and there is no node_modules to ship. --watch gives you the restart-on-save behaviour you would normally install nodemon for. node --test finds and runs every *.test.js file with no config file and no Jest.

The "type": "module" line matters. It turns on ESM so import works without a build step or a .mjs extension.

Building Both Services Step by Step

  1. 1

    Build the user service with node:http

    The user service holds user records and answers GET /users/:id. Create user-service/index.js with the package.json shown above alongside it.

    javascript — user-service/index.js
    import { createServer } from 'node:http';
    
    const users = new Map([
      ['1', { id: '1', name: 'Amir Khan', email: 'amir@example.com' }],
      ['2', { id: '2', name: 'Sara Ali', email: 'sara@example.com' }],
    ]);
    
    function json(res, status, payload) {
      res.writeHead(status, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(payload));
    }
    
    const server = createServer((req, res) => {
      const url = new URL(req.url, `http://${req.headers.host}`);
    
      if (req.method === 'GET' && url.pathname.startsWith('/users/')) {
        const id = url.pathname.split('/')[2];
        const user = users.get(id);
    
        if (!user) return json(res, 404, { error: 'user not found' });
        return json(res, 200, user);
      }
    
      json(res, 404, { error: 'not found' });
    });
    
    const PORT = process.env.PORT || 3001;
    server.listen(PORT, () => {
      console.log(`[user-service] listening on ${PORT}`);
    });

    No routing library. URL is a global in Node, so parsing the path and branching on it with a couple of if statements is genuinely fine at this scale. The small json() helper exists because writing the header and the body separately three times gets noisy fast, and that helper is the seed of what a framework would give you.

    Start it with npm run dev and you have a service.

    Terminal window running npm run dev in the user-service directory, showing Node's watch mode banner followed by the log line user-service listening on 3001, with no npm install step and no node_modules directory present
    node --watch restarts the service on every save. No nodemon, no install step, no node_modules folder.
  2. 2

    Test it with the built-in runner

    node:test covers what most services need: a test function, assertions, async support, and a readable TAP report. Create user-service/index.test.js.

    javascript — user-service/index.test.js
    import { test } from 'node:test';
    import assert from 'node:assert';
    
    const BASE = 'http://localhost:3001';
    
    test('fetches an existing user', async () => {
      const res = await fetch(`${BASE}/users/1`);
      const body = await res.json();
    
      assert.strictEqual(res.status, 200);
      assert.strictEqual(body.name, 'Amir Khan');
    });
    
    test('returns 404 for a missing user', async () => {
      const res = await fetch(`${BASE}/users/999`);
      assert.strictEqual(res.status, 404);
    });

    Run npm test while the service is up in another terminal.

    ⚠ This is an integration test, not a unit test

    These tests hit a live server, so they fail if the service is not already running. That is a deliberate simplification to keep the example down to two commands. In a real project, export the request handler from a separate module and pass it to a server the test starts and stops itself, so npm test works from a cold checkout.

  3. 3

    Build the order service and call across the boundary

    This is where inter-service communication actually happens. The order service confirms a user exists before creating an order for them, and it does that with a plain fetch call.

    javascript — order-service/index.js
    import { createServer } from 'node:http';
    
    const orders = [];
    const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://localhost:3001';
    
    function json(res, status, payload) {
      res.writeHead(status, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(payload));
    }
    
    // fetch rejects on a refused connection, so a downed user service must not
    // escape this function as an unhandled rejection and take the process with it.
    async function getUser(userId) {
      try {
        const res = await fetch(`${USER_SERVICE_URL}/users/${userId}`);
        if (!res.ok) return null;
        return await res.json();
      } catch {
        return null;
      }
    }
    
    const server = createServer(async (req, res) => {
      if (req.method === 'POST' && req.url === '/orders') {
        let body = '';
        for await (const chunk of req) body += chunk;
    
        let payload;
        try {
          payload = JSON.parse(body);
        } catch {
          return json(res, 400, { error: 'invalid JSON body' });
        }
    
        const user = await getUser(payload.userId);
        if (!user) {
          return json(res, 400, { error: 'cannot create order, user unavailable' });
        }
    
        const order = {
          id: String(orders.length + 1),
          userId: payload.userId,
          item: payload.item,
          userName: user.name,
        };
        orders.push(order);
    
        return json(res, 201, order);
      }
    
      json(res, 404, { error: 'not found' });
    });
    
    const PORT = process.env.PORT || 3002;
    server.listen(PORT, () => {
      console.log(`[order-service] listening on ${PORT}, user service at ${USER_SERVICE_URL}`);
    });

    getUser is a plain fetch call. No axios, no HTTP client library, no service discovery layer. The order service only needs a URL, and at two services a URL in an environment variable is the entire service discovery story.

    The two try blocks are not boilerplate, they are the whole lesson. fetch rejects when a connection is refused rather than returning a non-ok response, so a user service that is simply down throws rather than returning a 404. Without that catch, an unhandled rejection inside the request handler takes the order service down with it: one dead service becomes two.

  4. 4

    Run both services and watch a failure happen

    Open two terminal tabs, run npm run dev in each folder, then send a request to the order service from a third.

    bash
    curl -X POST http://localhost:3002/orders \
      -H "Content-Type: application/json" \
      -d '{"userId":"1","item":"keyboard"}'
    
    # {"id":"1","userId":"1","item":"keyboard","userName":"Amir Khan"}

    That userName field came from a different process over the network. That is the entire mechanism behind every microservice architecture, and you wrote it in about forty lines.

    Split terminal with three panes, the top left running the user service on port 3001, the top right running the order service on port 3002, and the bottom pane showing a curl POST to the orders endpoint returning a JSON order that includes the userName field resolved from the user service
    Two independent processes, one fetch call between them, and a response assembled from both.

    Now stop the user service and run the same curl again. You get a clean 400 with "user unavailable" and the order service stays up. Delete the try/catch from getUser and try once more: the process logs an unhandled rejection and the request hangs until curl gives up.

    That is the first real lesson of distributed systems, and it is much better learned in a forty-line service than in production. Any call to another service can fail, and yours has to decide what that means.

The Challenges You Hit Immediately

This setup works, and it is honest about where it stops working. Every gap below maps to a specific tool people normally install on day one without knowing what it is for.

No service discovery

The order service reads the user service's URL from an environment variable. That is fine for two services on one machine. With ten services spread across hosts that restart and move, something has to answer "where is the user service right now," which is exactly what Consul and Kubernetes' internal DNS exist to do.

No retries or circuit breaking

Right now one failed call means one failed order. Most failures between healthy services are transient, so a retry with backoff recovers them for free:

javascript — order-service/index.js — replacing getUser
async function getUserWithRetry(userId, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try {
      const res = await fetch(`${USER_SERVICE_URL}/users/${userId}`);
      if (res.ok) return await res.json();
      if (res.status === 404) return null; // do not retry a real answer
    } catch {
      // network-level failure, fall through to the backoff below
    }
    if (i < attempts - 1) {
      await new Promise((r) => setTimeout(r, 200 * 2 ** i));
    }
  }
  return null;
}

⚠ Warning

Never retry a 404. It is a successful answer that happens to be negative, and retrying it triples your latency to reach the same conclusion. Retry connection errors, timeouts, and 5xx responses only.

That is a start, not a solution. A real circuit breaker stops calling a service that has been failing for a while, so a slow dependency cannot exhaust every connection in the calling service and drag it down too.

No structured logging

console.log with a service name prefix is the floor, and it works for two services on your laptop. Once several instances of several services are running, you need a request ID generated at the edge and passed along in a header, so a single user action can be traced across every service that touched it. That is the job pino and OpenTelemetry do together.

No process management

If either service crashes, it stays down until you restart it by hand. In production a supervisor (PM2, a systemd unit, or a container orchestrator's restart policy) handles that. Deploying two small services is also the point where a CI pipeline stops being optional; our GitHub Actions tutorial covers building one from scratch, and the same workflow file scales to both services with a build matrix.

None of these gaps are reasons to avoid this approach for learning or for a small project. They are the concrete reasons the heavier tools exist, which is far more useful than being told to install Docker without being told what it fixes.

Why Starting Here Beats Starting With the Full Stack

Building your first services this way buys you four things that are hard to get any other way.

  • You understand every line — there is no framework magic between your code and the socket, so when something breaks the stack trace points at code you wrote.
  • It costs nothing to run — two Node processes fit on a free-tier VM, which is enough to find out whether splitting the app was even the right call.
  • Onboarding is two files — a new developer reads index.js twice and understands the whole system, instead of reverse-engineering a compose file with four services and a broker.
  • The work is not throwaway — the service boundary you draw here survives every later upgrade.

That last point is the one worth sitting with. Adding Express later changes how requests are routed inside a service. Adding Docker changes how a service is packaged. Adding a queue changes how one message travels. None of them change the decision that users belong to one service and orders to another. You are adding tooling around a boundary you already drew correctly, not rewriting it.

Before and after architecture diagram, on the left a single monolith box containing user logic and order logic sharing one database, on the right two separate service boxes each with its own database connected by a labelled HTTP fetch arrow from order service to user service
The split that matters is on the right: separate processes and separate data, connected by one HTTP call.

When to Actually Add Express, Docker, or a Queue

This approach has a ceiling and it is worth being straight about where it sits. Each tool below earns its place at a specific, recognisable moment:

Add each tool when you hit its trigger, not before.
ToolAdd it whenWhat it actually solves
Express or FastifyA service passes roughly five or six routes, or needs auth on every routeRouting and middleware you would otherwise hand-roll with growing if statements
DockerA second developer needs to run this, or you deploy to a shared environment"Works on my machine", by pinning the runtime and OS alongside the code
A message queueA call does not need a response, like sending a welcome email after signupTemporal coupling, so the sender no longer needs the receiver to be up right now
A shared cacheThe same cross-service call repeats on nearly every requestRedundant network round trips between services
KubernetesRestarting and load balancing instances by hand is a real time costScheduling and self-healing across many instances of many services

The Express row is the one you will hit first. Its middleware chain is what makes cross-cutting concerns tractable, and it is also where subtle bugs live once request handling gets more complex; our write-up on the HTTP QUERY method in Express and Node.js gets into how that request layer behaves. When several services need coordinating conventions rather than just a router, a batteries-included framework starts to look reasonable, and the trade-offs there are covered in our NestJS interview questions guide.

The caching row is easy to reach too. The order service calls the user service on every single order, and most of those calls return a user that has not changed in weeks; our guide to caching strategies covers which pattern fits that shape of read.

The point is not to avoid these tools. It is to add each one because you hit the problem it solves, not because a tutorial told you to install it before you had written any code.

Frequently Asked Questions

Is it actually production-ready to build microservices with just node:http?

Yes, for a small number of services under light to moderate traffic. node:http is the same server Express runs on top of, so raw throughput is not the limitation. What you give up is everything around the request, not the request itself.

  • Safe to ship — internal services, side projects, and small-team apps with a handful of routes each
  • Needs more first — anything public-facing, which wants rate limiting, request validation, and a supervisor at minimum
  • Outgrown it when — you need service discovery across many instances, distributed tracing, or async messaging
How is this different from using Express with a minimal setup?
node:httpExpress
Install stepNone`npm i express`
RoutingManual `if` on method and path`app.get('/users/:id')`
Middleware chainNone, compose by handBuilt in
Body parsingManual, ~4 lines`express.json()`
Error handlingPer-handler `try/catch`Central error middleware

At two routes the difference is a few lines. At fifteen routes with auth on most of them, Express is clearly worth it. Switch when the if chain starts to feel like a router you are writing badly.

What happens when one service is down and the other calls it?

fetch rejects on a refused connection rather than returning a response object with an error status. An unhandled rejection inside a request handler can take the calling service down too, so the call needs its own try/catch:

javascript
async function getUser(userId) {
  try {
    const res = await fetch(`${USER_SERVICE_URL}/users/${userId}`);
    if (!res.ok) return null;   // service answered, just not with a user
    return await res.json();
  } catch {
    return null;                // service did not answer at all
  }
}

⚠ Warning

Also set an explicit timeout with AbortSignal.timeout(2000). Without one, a hung dependency holds your request open indefinitely, which is worse than a fast failure.

Can these two services share a database?

They can, and at that point you no longer have microservices. You have a monolith split across two processes, paying the network cost without gaining independent deployability.

  • The problem — a schema change to the users table now requires coordinating a deploy of both services, which is exactly what you were trying to avoid
  • The rule — each service owns its tables, and other services ask for the data over the API
  • The pragmatic middle — one database instance with a separate schema per service and no cross-schema queries is a reasonable early compromise
Do I need TypeScript for this?

No. Everything here runs as plain JavaScript on Node's native ESM support, with no build step at all, which is part of why the whole setup stays understandable.

TypeScript becomes worth it once services exchange non-trivial payloads, because the response shape of one service is an implicit contract the other depends on. Node can run .ts files directly on recent versions, though type stripping has moved around between releases, so check what your version supports before relying on it.

How many services can I run this way before it breaks down?

The limit is operational, not technical. Node handles the traffic long before you run out of patience managing the processes.

  1. Two or three services — comfortable, URLs in env vars are genuinely enough
  2. Four to six — starting to strain, you will want a process manager and a script to start everything at once
  3. Seven or more — past the ceiling, hand-managed URLs and manual restarts cost more than the tooling would

ℹ Info

The trigger is usually not the service count itself. It is the first time you cannot remember which ports are in use, or you deploy a service and forget to update a URL somewhere else.

Should services talk over HTTP or something faster like gRPC?

Start with HTTP and JSON. It is debuggable with curl, readable in logs, and needs no schema compilation step or code generation in your build.

  • HTTP and JSON — the default, and correct until you have measured a reason to change
  • gRPC — worth it for high-volume internal calls where serialisation cost is measurable and a strict schema is a feature
  • A message queue — the right answer when the caller does not need a response at all, which is a different problem from speed

Swapping the transport later means rewriting getUser and its counterpart endpoint. That is a contained change precisely because the service boundary is already in the right place.

Two files, zero installs, and one fetch call is a complete microservice setup you can reason about end to end. Build it, break it by killing one service, and then add Express, Docker, or a queue when you can name the problem each one is fixing.

Written by

Zeeshan Tofiq, Full Stack Developer
Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

All articles by Zeeshan TofiqGitHubLinkedIn

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

nodejs

30 Node.js Interview Questions and Answers (2026)

30 Node.js interview questions with full answers: event loop, streams, clustering, worker threads, memory leaks, and security. Updated for 2026.

Jun 8, 2026·37 min read
nodejs

30 NestJS Interview Questions and Answers (2026)

30 NestJS interview questions with full answers: modules, DI, guards, pipes, interceptors, JWT auth, microservices, and testing. Updated for 2026.

Jun 8, 2026·31 min read
backend

HTTP QUERY Method in Express and Node.js

How to actually implement the new HTTP QUERY method (RFC 10008) in Express and Node.js, with working code for a search endpoint, CORS, and a POST fallback.

Jul 7, 2026·13 min read

On this page

  • Why Skip the Usual Stack
  • What Actually Makes Something a Microservice
  • The Approach: Two Services, Zero Dependencies
  • Building Both Services Step by Step
  • The Challenges You Hit Immediately
  • Why Starting Here Beats Starting With the Full Stack
  • When to Actually Add Express, Docker, or a Queue
  • Frequently Asked Questions