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.
On this page
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.

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.
project/
user-service/
package.json
index.js
index.test.js
order-service/
package.json
index.js
index.test.jsTwo 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:
{
"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
Build the user service with node:http
The user service holds user records and answers
GET /users/:id. Createuser-service/index.jswith the package.json shown above alongside it.javascript — user-service/index.jsimport { 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.
URLis a global in Node, so parsing the path and branching on it with a couple ofifstatements is genuinely fine at this scale. The smalljson()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 devand you have a service.
node --watch restarts the service on every save. No nodemon, no install step, no node_modules folder. - 2
Test it with the built-in runner
node:testcovers what most services need: a test function, assertions, async support, and a readable TAP report. Createuser-service/index.test.js.javascript — user-service/index.test.jsimport { 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 testwhile the service is up in another terminal. - 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
fetchcall.javascript — order-service/index.jsimport { 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}`); });getUseris a plainfetchcall. 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
tryblocks are not boilerplate, they are the whole lesson.fetchrejects 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 thatcatch, an unhandled rejection inside the request handler takes the order service down with it: one dead service becomes two. - 4
Run both services and watch a failure happen
Open two terminal tabs, run
npm run devin each folder, then send a request to the order service from a third.bashcurl -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
userNamefield 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.
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
400with"user unavailable"and the order service stays up. Delete thetry/catchfromgetUserand 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:
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;
}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.jstwice 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.

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:
| Tool | Add it when | What it actually solves |
|---|---|---|
| Express or Fastify | A service passes roughly five or six routes, or needs auth on every route | Routing and middleware you would otherwise hand-roll with growing if statements |
| Docker | A 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 queue | A call does not need a response, like sending a welcome email after signup | Temporal coupling, so the sender no longer needs the receiver to be up right now |
| A shared cache | The same cross-service call repeats on nearly every request | Redundant network round trips between services |
| Kubernetes | Restarting and load balancing instances by hand is a real time cost | Scheduling 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:http | Express | |
|---|---|---|
| Install step | None | `npm i express` |
| Routing | Manual `if` on method and path | `app.get('/users/:id')` |
| Middleware chain | None, compose by hand | Built in |
| Body parsing | Manual, ~4 lines | `express.json()` |
| Error handling | Per-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:
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
}
}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.
- Two or three services — comfortable, URLs in env vars are genuinely enough
- Four to six — starting to strain, you will want a process manager and a script to start everything at once
- Seven or more — past the ceiling, hand-managed URLs and manual restarts cost more than the tooling would
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.
Related Articles
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.
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.
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.