John Weldon

Per-Region Service Routing with NATS Subject Mappings

When the same application runs in multiple regions, where does a request go? You can encode the region in the subject (payments.region1.process) and make every app region-aware. You can stand up a routing proxy and accept the extra hop. Or you can let the messaging fabric handle it: applications publish to a generic subject, and infrastructure decides per region where the request lands.

NATS subject mappings with cluster-scoped destinations do this cleanly: one mapping, shared by every server, that resolves differently in each cluster. This post walks through the topology requirement, the configuration shape, how failover works, and the simpler alternative you should consider first.

Topology Matters: Cluster vs Super-Cluster

A subject mapping is applied where a message enters the system: at the server the publishing client (or leafnode) is connected to. Routes and gateways don’t carry mappings between servers, and a mapped message is not re-mapped when it crosses a gateway. In a config-file deployment each server reads its own file; in operator/JWT mode each server loads the same account JWT from its resolver.

What lets one mapping mean different things in different places is the cluster field on a mapping destination. A server uses the destinations scoped to its own cluster name, and falls back to the unscoped destinations when none match.1

The per-region routing pattern requires a super-cluster. If you have a single cluster spanning regions (“stretch cluster”), every server has the same cluster name, so a cluster-scoped destination can’t tell the regions apart and payments.process means the same thing everywhere.

The Pattern

Two clusters in a super-cluster, region1 and region2, each running a regional copy of a payment-processing service. Applications publish to payments.process regardless of where they’re deployed. One mapping, identical on every server, sends the message to the local region’s service:

accounts {
    PAYMENTS {
        mappings = {
            "payments.process": [
                {dest: "services.region1.payments", weight: 100%, cluster: "region1"}
                {dest: "services.region2.payments", weight: 100%, cluster: "region2"}
            ]
        }
    }
}

Each region’s payment service subscribes to its regional subject (services.region1.payments or services.region2.payments). Applications never know which one they’re hitting – they publish to payments.process and the local server rewrites the destination. Weights are totaled per cluster, not across the whole mapping: each scoped entry is the only destination for its cluster, so each is 100% of that cluster’s traffic. In a config file, weight is required on every destination, even at 100%.

In operator/JWT mode the same mapping, cluster field included, lives in the account JWT, so every cluster gets it without per-cluster files.

How the Mapping Resolves

The mapping is applied at the server where the publish originates, before any gateway hop. A client connected to region1 publishing payments.process has the subject rewritten to services.region1.payments at the local server; the message routes from there. The mapping does not re-apply in transit – mappings are not chained across gateways.

This is what makes the pattern work: the application publishes a generic subject, and each cluster resolves it for its locally-connected clients.

Failover

For active/standby routing across regions, change the mapping and reload. Suppose region1 is failing and all payment traffic should go to region2. Replace the two scoped destinations with a single unscoped one:

accounts {
    PAYMENTS {
        mappings = {
            "payments.process": [
                {dest: "services.region2.payments", weight: 100%}
            ]
        }
    }
}

With no scoped destinations left, every server falls back to this one, in both clusters. The obvious edit – pointing region1’s scoped entry at services.region2.payments – is rejected: a destination subject can appear only once per mapping, even across clusters. On reload the server logs Failed to reload server configuration: ... duplicate entry for "services.region2.payments" and keeps running with the previous mapping, so traffic does not fail over. Check the log after every reload. In operator/JWT mode, through at least v2.15.0, the same mistake is discarded without an error and the old mapping stays in place (nats-server#8468; the fix is merged but not in a release as of September 2026).

Then reload every server:

# If pid_file is set in server.conf:
nats-server --signal reload

# Otherwise, supply the PID explicitly:
nats-server --signal reload=<pid>
# or: kill -HUP $(pgrep -x nats-server)

In operator/JWT mode, update the account JWT and push it to the resolver instead.

No application restarts. No client reconnections. The next request published in region1 routes across the gateway to region2’s service. Failing back is restoring the scoped version.

There is a short cutover window: requests already in flight when the reload signal is processed may be delivered to the old destination. For low-volume workloads this is negligible; for high-throughput services, expect a brief overlap and design retries accordingly.

Per-cluster config files. If you run config files and would rather give each cluster its own file with a plain unscoped mapping, that also works: each server applies what it reads. But the regions then differ only because the files differ, nothing checks that servers within a cluster agree, and the approach does not carry over to operator/JWT mode, where every cluster gets the same account. The cluster-scoped mapping keeps one source of truth.

The Simpler Alternative

Before reaching for subject mappings, consider whether a plain service-import pattern is enough. An account can import services from different source accounts – say services.region1.payments from one and services.region2.payments from another – and the caller picks which one to invoke.

This works when the application or an intermediary is willing to select between two named subjects. It does not require a super-cluster, it does not require config reloads to fail over (the application or a service mesh can pick), and the routing logic is visible in the caller’s code rather than in cluster configuration.

The subject-mapping pattern is the better fit when:

If you have a single cluster spanning regions, neither approach gives you per-region mapping within an account. Stretch-cluster topology limits you to homogeneous routing; reach for a super-cluster first.

Operational Caveats

A few things worth knowing before relying on this pattern in production:


  1. Subject Mapping – see Cluster-scoped destinations. The fallback to unscoped destinations is in server/accounts.go in nats-server v2.15.0; cluster-scoped destinations have existed since v2.2.0. Weighted mappings use the same configuration shape to split traffic by percentage – useful for canary deployments inside a single region, but a separate dimension from per-region routing. ↩︎