# Do your Node.js back-ends really need service discovery?

Source: https://imqueue.org/blog/do-nodejs-backends-need-service-discovery/
Published: 2026-07-12
Updated: 2026-07-29
Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus)

**Service discovery exists because HTTP calls an address.** To call service B you need a host and a port, and because instances come and go, you need something keeping a live map of where a healthy B is right now — Consul, etcd, Eureka, DNS-SD, or your platform's registry. Route calls through a message queue instead and the question dissolves: a service reads from its own *named* queue, so the queue name is the address and there's no host to resolve. That's a real simplification, and it costs you things worth knowing about before you choose it.

## What service discovery is really for

Discovery is the answer to "where is a healthy instance of B?", and it's usually paired with health checks and a load balancer or mesh. It's the right tool when:

- You have a **polyglot** fleet where many kinds of clients need to locate services.
- You're already invested in a **service mesh** and want its traffic policies, mTLS and observability.
- You need discovery for things **beyond RPC** — config distribution, leader election, distributed coordination.

If that's you, keep it; it earns its place. A queue replaces the addressing job only.

## What changes with a queue

With `@imqueue`, a service consumes from a queue named after it, and instances of that service compete on the same queue:

- All instances read the same named queue, so *which* instance handles a call is decided by consumption, not by a registry.
- Starting or stopping instances changes nothing anyone has to discover — they attach to or detach from the same queue.
- There's no live host map, and no health-check plumbing feeding a registry.

The caller just uses a generated client:

```typescript
import { user } from './clients/index.js';

const client = new user.UserClient({ callTimeout: 5000 });
await client.start();

const found = await client.get('42'); // no host, no port, no registry lookup
```

The caller never knows or cares how many instances of the `User` service exist, or where they run. [Load balancing without a load balancer](https://imqueue.org/blog/load-balancing-microservices-without-a-load-balancer/) covers the distribution side of the same mechanism.

## The nice consequence: boot order stops mattering

Order-independent boot is the part people don't expect. Because an `@imqueue` call is a message on a queue rather than a connection to a host, a caller can send before the callee exists. The message waits. Services can start in **any order**, and a service that isn't up yet simply accumulates work until a consumer appears.

That removes a whole category of orchestration: no readiness gates between services just so A doesn't crash-loop while B boots, no retry-with-backoff on startup, no dependency ordering in your compose file.

One caveat that matters: this holds for **pre-generated static clients**, the ones you commit. A *dynamic* client created at runtime with `IMQClient.create()` has to ask the running service for its description, so it does require the target to be up. If order-independence is the property you want, generate your clients ahead of time.

## What replaces each job discovery was doing

| Discovery's job | With a queue |
|---|---|
| Find a host for B | Nothing to find — the queue name is the address |
| Balance across B's instances | Instances compete on the queue |
| Notice B is unhealthy and route around it | Nothing routes; unconsumed work waits on the queue |
| Notice B is gone entirely | **Nothing tells you** — see below |
| Distribute config, elect leaders | Not covered; still your problem |

Four of five rows get simpler. The fourth is the one to think hard about.

## The honest cost: "where is B" becomes "is B ever coming?"

A registry with health checks gives you an explicit answer to "is B alive". A queue gives you no such thing, and that trade is the real content of this decision.

With HTTP plus discovery, a call to a service with no healthy instances fails fast and loudly — connection refused, or a `503` from the mesh. With a queue, the same call is indistinguishable from a call to a service that's merely busy. It waits. Two consequences:

- **Set `callTimeout`.** It's unset by default, which means a call to a service that never answers stays pending *forever*. This is the single most important option to set in production, and the queue model is exactly why.
- **A timeout is not a diagnosis.** `IMQ_RPC_CALL_TIMEOUT` tells you nobody answered in time. It doesn't distinguish "still booting", "overloaded", "crash-looping" and "never deployed". A registry would have told you which.

So you don't escape observability work — you relocate it. Instead of registry health, you watch **queue depth**: `@imqueue`'s optional metrics server exposes a `queue_length` metric, and a queue growing without bound is the signal that a consumer has stopped consuming.

Read that metric carefully, though. `queueLength()` excludes messages that aren't due yet and messages currently leased under safe delivery, so it isn't a measure of outstanding work — and it returns `0` when there's no writer connection, which makes "disconnected" look identical to "empty". Alert on *growth*, not on absolute depth, and don't treat zero as healthy on its own.

## A fair accounting of the rest

- **You depend on the queue being available** the way you'd depend on a registry or mesh. With `@imqueue` that's Redis, in the path of every internal call — a stateful dependency whose availability becomes your RPC layer's availability. For most teams it's infrastructure they already run; it's still a dependency you should reason about rather than wave through.
- **Delivery is at-least-once.** Because a message can be delivered twice, exposed methods should be idempotent. That's a design constraint HTTP-plus-discovery doesn't impose. See [what guaranteed delivery costs](https://imqueue.org/blog/guaranteed-message-delivery-cost/).
- **Nothing drains in-flight work on shutdown.** A worker killed mid-handler loses that message. [Graceful shutdown and zero-drop deploys](https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/) works through what closing that gap actually takes.
- **Non-RPC needs are untouched.** Config distribution, leader election and coordination still need the right tool; a queue doesn't replace them.
- **Cross-language fleets** may still want a language-neutral discovery story — [gRPC is the better answer there](https://imqueue.org/blog/grpc-vs-message-queue-rpc/).

## So does your Node.js back-end need service discovery?

A short test. You probably **don't** need a discovery layer if:

- Your back-end services are all Node.js or TypeScript.
- The only reason you were about to install Consul is "so my services can find each other to make calls".
- You already run Redis, or don't mind adding it.
- You can make your handlers idempotent.

You probably **do** still need one if:

- Your fleet is polyglot, or clients outside your control need to locate services.
- You want a mesh's traffic policy, mTLS or tracing.
- You need discovery for config, coordination or leader election as well.
- Fail-fast semantics matter more to you than buffered ones — some systems genuinely want the call to explode immediately rather than wait.

## Frequently asked questions about service discovery in Node.js

### Is a message queue a replacement for Consul or etcd?
For service-to-service *addressing*, largely yes — the queue name is the address. For config distribution, leader election and coordination, no. Those are separate jobs a queue doesn't do.

### How do I know a service is healthy without a registry?
You watch the queue rather than the service. `@imqueue` can expose a `queue_length` metric; sustained growth means a consumer has stopped keeping up. You don't get a registry's per-instance health view, and you should set `callTimeout` so callers fail rather than hang.

### What happens if a service is down when someone calls it?
The message waits on its queue until a consumer appears, so the call isn't lost. The risk is the opposite of HTTP's: instead of failing fast, it can wait indefinitely — which is why `callTimeout` matters.

### Does this work across multiple machines?
Yes. Instances anywhere that can reach the same Redis compete on the same queue, so horizontal scaling needs no addressing changes.

### Do I still need a load balancer?
Not for internal service-to-service calls; competing consumers handle distribution. You'll still want something in front of your HTTP edge.

### Doesn't this just move the single point of failure to Redis?
It concentrates the dependency, yes — honestly, that's the trade. A registry or mesh is also a critical dependency; the question is which one you'd rather operate, and whether you're already running it.

---

If a queue-based model sounds like it fits, [**Getting Started**](https://imqueue.org/get-started/) shows how calls work with no addressing on your side, and [horizontally scalable Redis broker](https://imqueue.org/blog/horizontally-scalable-redis-broker/) covers what happens when one Redis isn't enough. Shipping inside a closed-source product? See [commercial licensing & support](https://imqueue.org/license/).

