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
- A NATS cluster is a full mesh of servers connected by routes, all sharing one cluster name.
- A super-cluster is multiple clusters, each with its own name, connected by gateway links.
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:
- Applications should be truly region-unaware – no awareness of two regional endpoints, no selection logic
- You want routing decisions to live in infrastructure configuration, not in application code
- Your topology already is a super-cluster
- You want failover triggered by an operator config change, not by application logic or a service-mesh policy
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:
- Reload requires a PID source.
nats-server --signal reloadneeds a pidfile (setpid_filein the server config) or an explicit PID argument. Without either, the signal has nowhere to land. Reload every server, not just one per cluster: each applies its own copy of the mapping. - Failover is operator-triggered. This pattern gives you application transparency, not automatic failover. The cutover happens when an operator edits config and sends a reload – there is no built-in health check or auto-switch.
- Mappings are not stream replication. Subject mappings rewrite the destination of an individual message at publish time. They are not a substitute for JetStream cross-domain sourcing if you need persistent stream replication across regions.
- Keep every server’s mapping identical. Nothing enforces it. A server with a different mapping silently routes its own clients differently from the rest of its cluster. Cluster-scoped destinations let the configs stay identical while the behavior differs by cluster.
-
Subject Mapping – see Cluster-scoped destinations. The fallback to unscoped destinations is in
server/accounts.goin 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. ↩︎