All posts

Mirror Streams in NATS JetStream: One-Way Replication Made Simple

Peter Humulock
Feb 18, 2026
Mirror Streams in NATS JetStream: One-Way Replication Made Simple

Mirror streams in NATS JetStream give you one-way, asynchronous replication of stream data. Think of it like a one-way mirror—data flows in from a single origin stream, automatically and continuously. No complex synchronization, no manual syncing, no fuss.

The Problem

You’ve got a stream of data in one region, but consumers in another region need access to it. Or maybe you need to replicate data across NATS accounts. Or you just want a read-only replica to spread the load so you’re not hammering a single stream.

Mirror streams solve all of these. They give you a faithful, read-only copy of your origin stream that stays in sync automatically—even across unreliable network connections.

How It Works

  1. You create a stream with a mirror configuration pointing to the origin
  2. Messages automatically replicate from the origin to the mirror
  3. The mirror preserves original metadata—sequence numbers and timestamps stay the same
  4. Consumers on the mirror work exactly like consumers on any other stream

Configuration is all done on the receiving side. Your origin stream doesn’t need any special setup—it won’t even see a consumer created for the replication.

The mirror relationship is strict: each mirror can only replicate from a single origin stream, and mirrors are read-only. Clients can’t publish directly to them. But consumers have full capabilities, and you can still delete messages from the mirror independently if needed.

Mirror Configuration Options

When setting up a mirror, the only required field is the name of the origin stream. But you’ve got several optional parameters:

  • Start Sequence — pick a starting sequence number if you don’t want to mirror from the beginning
  • Start Time — same idea, but based on a timestamp
  • Filter Subject — narrow replication to messages matching a specific subject pattern
  • Subject Transforms — remap subject names as messages replicate
  • Domain — specify which JetStream domain the origin stream lives in (useful for hub-and-leaf topologies)

One thing to note: you can’t use filter subject and subject transforms together—pick one.

When to Use Them

  • Geographic distribution — spin up mirrors in different regions to bring data closer to consumers. One origin can feed multiple mirrors, perfect for fan-out topologies
  • Cross-account replication — replicate data across different NATS accounts or domains
  • Load distribution — enable mirror_direct so clients read from mirror replicas instead of routing back to the origin
  • Independent backups — since deletes don’t replicate, mirrors serve as independent copies of your data

What You Can’t Do

This is important to understand:

  • Deletes don’t replicate in either direction. Delete a message from the origin, the mirror still has it. Delete from the mirror, the origin is unaffected. This is by design.
  • Changes don’t flow backward. Mirrors are strictly one-way.
  • Work queue retention has only partial mirror support and isn’t resilient over intermittent connections like leaf nodes.
  • Interest-based retention is not supported with mirrors. The behavior is undefined—don’t try it.

Mirrors are generally tolerant of the origin being temporarily unreachable. When the connection drops, the mirror waits about 10–20 seconds before retrying.

CLI Examples

The cluster setup we use in this example.

Mirror streams replication flow

Setup

Start by watching the stream report so you can see changes in real time:

Terminal window
watch nats str report

Create a primary stream in cluster 1:

Terminal window
nats str add events \
--cluster c1 \
--subjects 'events.>' \
--defaults

Create a mirror of that stream in cluster 2:

Terminal window
nats str add events2 \
--cluster c2 \
--mirror events \
--defaults

The stream report will show both streams, with the mirror appearing in the replication section.

Publishing and Replicating

Publish some messages to the primary:

Terminal window
nats pub events.order '{"id": 1, "item": "widget"}'
nats pub events.order '{"id": 2, "item": "gadget"}'
nats pub events.order '{"id": 3, "item": "gizmo"}'

Check the stream report—both streams should show three messages. Read the last message from the mirror to confirm:

Terminal window
nats str get events2 --last-for 'events.>'

Mirrors Are Read-Only

Try publishing directly to the mirror—this fails because mirrors have no bound subjects and cannot accept direct publishes:

Terminal window
nats req events.order '{"id": 4, "item": "doohickey"}' -H Nats-Expected-Stream:events2

Publish to the primary instead—this works:

Terminal window
nats req events.order '{"id": 4, "item": "doohickey"}' -H Nats-Expected-Stream:events

Metadata Is Preserved

Compare the state of both streams:

Terminal window
nats str info events -j | jq '.state'
nats str info events2 -j | jq '.state'

Sequence numbers and timestamps match. The mirror is a faithful copy.

Deletes Are Independent

Delete a message from the origin:

Terminal window
nats str rmm events 2 --force

The mirror still has it:

Terminal window
nats str get events2 2

Delete a message from the mirror:

Terminal window
nats str rmm events2 1 --force

The origin still has it:

Terminal window
nats str get events 1

Deletes on one side don’t affect the other. This makes mirrors useful as independent backups.

Mirror Promotion (NATS 2.12)

NATS 2.12 introduced mirror promotion—if your origin stream goes down permanently, you can promote a mirror to become the new primary.

The process:

  1. Unbind subjects from the old primary so you don’t have two streams competing for the same subjects
  2. Remove the mirror configuration from the mirror and bind the subjects to it
  3. Once the promoted stream is healthy, clean up the old one

Keep in mind: the server won’t verify if the mirror is fully caught up, so any unreplicated messages will be lost.

Promotion Demo

Start a continuous publisher sending messages to the primary. Using nats bench ensures automatic retries on failed publishes:

Terminal window
nats bench js pub sync \
--stream events \
--multisubject \
--sleep 500ms 'events'

Messages flow into the primary and replicate to the mirror in real time.

Now, unbind subjects from the primary by setting them to $devnull—a special placeholder that keeps the stream valid but stops it from accepting new publishes on the original subjects:

Terminal window
nats str edit events \
--subjects '$devnull' \
--force

Then promote the mirror by removing its mirror configuration and binding the original subjects:

Terminal window
nats str edit events2 \
--no-mirror \
--subjects 'events.>' \
--force

And just like that, events2 is now the new primary stream accepting writes directly. The publisher reconnects and messages start flowing into events2.

Note: there’s a brief window between unbinding the old primary and promoting the mirror where publishes will fail. Your clients need retry logic to handle this gracefully.

You can now clean up the old stream:

Terminal window
nats str rm events --force

Go Code Example

Here’s a Go program that demonstrates mirror streams end-to-end—replication, metadata preservation, read-only behavior, and independent deletes:

1
package main
2
3
import (
4
"context"
5
"fmt"
6
"log"
7
"time"
8
9
"github.com/nats-io/nats.go"
10
"github.com/nats-io/nats.go/jetstream"
11
)
12
13
func main() {
14
nc, err := nats.Connect(nats.DefaultURL)
15
if err != nil {
16
log.Fatal(err)
17
}
18
defer nc.Close()
19
20
js, err := jetstream.New(nc)
21
if err != nil {
22
log.Fatal(err)
23
}
24
25
ctx := context.Background()
26
27
// Clean up previous runs
28
_ = js.DeleteStream(ctx, "EVENTS")
29
_ = js.DeleteStream(ctx, "EVENTS_MIRROR")
30
31
// --- Setup ---
32
fmt.Println("--- Setup ---")
33
34
_, err = js.CreateStream(ctx, jetstream.StreamConfig{
35
Name: "EVENTS",
36
Subjects: []string{"events.>"},
37
})
38
if err != nil {
39
log.Fatal(err)
40
}
41
fmt.Println("Created stream: EVENTS (subjects: events.>)")
42
43
_, err = js.CreateStream(ctx, jetstream.StreamConfig{
44
Name: "EVENTS_MIRROR",
45
Mirror: &jetstream.StreamSource{
46
Name: "EVENTS",
47
},
48
})
49
if err != nil {
50
log.Fatal(err)
51
}
52
fmt.Println("Created mirror: EVENTS_MIRROR")
53
54
// --- Publish and Verify Replication ---
55
fmt.Println("\n--- Replication ---")
56
57
for i := 1; i <= 5; i++ {
58
ack, err := js.Publish(ctx, fmt.Sprintf("events.order.%d", i),
59
fmt.Appendf(nil, `{"id": %d}`, i))
60
if err != nil {
61
log.Fatal(err)
62
}
63
fmt.Printf(" Published seq %d to EVENTS\n", ack.Sequence)
64
}
65
66
// Give the mirror time to replicate
67
time.Sleep(time.Second)
68
69
mirror, err := js.Stream(ctx, "EVENTS_MIRROR")
70
if err != nil {
71
log.Fatal(err)
72
}
73
74
fmt.Println(" Reading from mirror:")
75
for seq := uint64(1); seq <= 5; seq++ {
76
msg, err := mirror.GetMsg(ctx, seq)
77
if err != nil {
78
fmt.Printf(" seq %d — error: %v\n", seq, err)
79
continue
80
}
81
fmt.Printf(" seq %d%s%s\n", msg.Sequence, msg.Subject, string(msg.Data))
82
}
83
84
// --- Metadata Preservation ---
85
fmt.Println("\n--- Metadata Preservation ---")
86
87
primary, err := js.Stream(ctx, "EVENTS")
88
if err != nil {
89
log.Fatal(err)
90
}
91
92
for seq := uint64(1); seq <= 3; seq++ {
93
pMsg, _ := primary.GetMsg(ctx, seq)
94
mMsg, _ := mirror.GetMsg(ctx, seq)
95
fmt.Printf(" Seq %d — primary: %s | mirror: %s | match: %v\n",
96
seq,
97
pMsg.Time.Format(time.RFC3339Nano),
98
mMsg.Time.Format(time.RFC3339Nano),
99
pMsg.Time.Equal(mMsg.Time))
100
}
101
102
// --- Read-Only ---
103
fmt.Println("\n--- Read-Only ---")
104
105
mirrorInfo, _ := mirror.Info(ctx)
106
fmt.Printf(" Mirror subjects: %v (empty — clients can't publish directly)\n",
107
mirrorInfo.Config.Subjects)
108
109
// --- Delete Behavior ---
110
fmt.Println("\n--- Delete Behavior ---")
111
112
fmt.Println(" Deleting seq 2 from primary...")
113
if err := primary.DeleteMsg(ctx, 2); err != nil {
114
log.Fatal(err)
115
}
116
117
_, err = primary.GetMsg(ctx, 2)
118
fmt.Printf(" Primary seq 2: %s\n", status(err))
119
120
_, err = mirror.GetMsg(ctx, 2)
121
fmt.Printf(" Mirror seq 2: %s\n", status(err))
122
123
fmt.Println("\nDone.")
124
}
125
126
func status(err error) string {
127
if err != nil {
128
return "deleted"
129
}
130
return "still present"
131
}

Wrapping Up

Mirror streams are your go-to for one-way replication of JetStream data—across regions, accounts, or unreliable networks—without the complexity of full synchronization. Just remember: they’re read-only, deletes don’t replicate in either direction, and they work best with standard retention policies.

And with mirror promotion in NATS 2.12, you’ve got a clean failover path if your origin ever goes down for good.


We’re building the future of NATS at Synadia. Want more content like this? Subscribe to our newsletter

Get the NATS Newsletter

News and content from across the community


© 2026 Synadia Communications, Inc.
Cancel