Star vs Mesh Replication Topologies: A Decision Guide
You have a fleet of CouchDB nodes that must stay in sync and one unavoidable choice to make before you write a single _replicator document: do the nodes funnel through a central relay, replicate directly with every peer, or fan out through a tree? That single decision fixes how many replication links you will operate, how large your conflict surface grows, how far a single node failure propagates, and how much WAN traffic you pay for every day. This guide compares the star (central relay), full mesh, and tree topologies for CouchDB 3.x on concrete axes — link count, conflict surface, failure domains, latency in hops, and operational cost — and gives a decision procedure keyed to fleet size and connectivity. It sits under Sync Topology Models, which frames a topology as a configurable, observable artifact rather than tribal knowledge.
The core tension is visible the moment you count edges. A star with N leaf nodes needs one bidirectional link per leaf — N links, 2N directed _replicator documents — because every leaf talks only to the relay. A full mesh needs a link between every pair, N(N-1)/2 links or N(N-1) directed documents, and that count grows quadratically. The diagram below contrasts the two at five nodes:
N(N-1) while the star climbs as 2N.Immediate Triage / Prerequisites
Before choosing, gather the four numbers that drive the decision. Guessing here is how a fleet ends up with 400 continuous jobs no one can account for.
- Fleet size
N. Count the databases that must converge, not the physical hosts. Ten devices sharing one database isN = 1for a given database; one database replicated to ten devices isN = 10. - Connectivity matrix. Which nodes can actually open a connection to which? Edge nodes behind NAT often cannot accept inbound connections at all, which quietly rules out a symmetric mesh and pushes you toward a relay. Confirm reachability now: from each node,
curl -sk https://peer.local:6984/should return the welcome document. - Write distribution. Do writes originate everywhere (true multi-master) or mostly at the edge with the centre read-mostly? Uniform writes everywhere is the case where a mesh’s conflict surface hurts most.
- Current job load. Read
curl -s http://localhost:5984/_scheduler/jobs | python3 -c "import sys,json;print(len(json.load(sys.stdin)['jobs']))"on the busiest node. Every link you add is a continuous job competing for the same scheduler budget covered in Scaling Peer-to-Peer Sync Beyond Three Nodes.
Prerequisites for the example below: Python 3.9+ and the standard library only — the generator emits _replicator documents you review before applying.
Step-by-Step Implementation
Follow this decision procedure. Each step ends with a check you can compute before committing to a wiring.
-
Compute the link budget for each candidate. For a star, links
= Nand documents= 2N. For a full mesh, links= N(N-1)/2and documents= N(N-1). For a balanced tree of branching factorb, links= N-1. Tabulate all three for yourN; if the mesh document count exceeds a few hundred you have already found your answer. -
Map the conflict surface. In a star, every write converges at one authority, so conflicts materialize and resolve in exactly one place. In a mesh, the same document edited on two peers becomes a conflict independently visible at every peer, multiplying the resolution work. Prefer a star when writes are uniform and a single merge authority is acceptable. The mechanics of how those forks arise are in Conflict Generation Models.
-
Trace the failure domains. In a star the relay is a single point of failure: lose it and every leaf stops converging, though leaves keep serving local reads and writes. In a full mesh there is no single choke point — any peer can drop and the rest continue — but a partition can leave sub-groups that diverge for the duration. Match this to your availability target.
-
Count the hops for propagation latency. A star delivers any write to any other node in two hops (leaf to relay, relay to leaf). A mesh delivers in one hop between any pair. A tree costs up to
2·log_b(N)hops. If sub-second cross-node visibility is a hard requirement and connectivity permits, a mesh wins on latency; otherwise the star’s extra hop is usually invisible against WAN round-trips. -
Price the operational cost. Documents to provision and monitor scale as
2N(star),N(N-1)(mesh), or2(N-1)(tree). WAN egress scales the same way because each link is an independent_changesstream. Choose the smallest topology that meets your latency and failure requirements — usually the star until a central authority becomes unacceptable.
Complete Working Example
The script below is a topology generator: give it a list of nodes and a topology name and it emits the exact _replicator documents for that wiring, plus the link budget, without touching a live server. Review the output, then POST each document to the relevant node’s _replicator database.
import json
import sys
from itertools import combinations
def _rep_doc(src_node: str, dst_node: str, db: str) -> dict:
"""One directed _replicator document: src_node's db -> dst_node's db."""
doc_id = f"rep__{src_node}__to__{dst_node}__{db}"
return {
"_id": doc_id,
"source": f"https://{src_node}:6984/{db}",
"target": f"https://{dst_node}:6984/{db}",
"continuous": True, # keep the edge live; see the scaling guide
"create_target": False, # fail fast rather than mint an empty db
"owner": "topology-generator",
}
def star(nodes, relay: str, db: str):
"""Every leaf links bidirectionally with one relay: N links, 2N docs."""
leaves = [n for n in nodes if n != relay]
for leaf in leaves:
yield _rep_doc(leaf, relay, db) # push leaf -> relay
yield _rep_doc(relay, leaf, db) # pull relay -> leaf
def mesh(nodes, db: str):
"""Every pair links both ways: N(N-1)/2 links, N(N-1) docs."""
for a, b in combinations(nodes, 2):
yield _rep_doc(a, b, db)
yield _rep_doc(b, a, db)
def build(topology: str, nodes, db: str, relay: str = None):
if topology == "star":
docs = list(star(nodes, relay or nodes[0], db))
elif topology == "mesh":
docs = list(mesh(nodes, db))
else:
raise ValueError(f"unknown topology: {topology!r}")
return docs
def link_budget(topology: str, n: int) -> str:
if topology == "star":
return f"star: {n} links, {2 * n} directed docs (linear)"
return f"mesh: {n * (n - 1) // 2} links, {n * (n - 1)} directed docs (quadratic)"
if __name__ == "__main__":
nodes = ["node-a.local", "node-b.local", "node-c.local", "node-d.local"]
db = "fleet_state"
topology = sys.argv[1] if len(sys.argv) > 1 else "star"
print(link_budget(topology, len(nodes)), file=sys.stderr)
docs = build(topology, nodes, db, relay="node-a.local")
if len(docs) > 200: # a soft guardrail: a mesh past ~15 nodes explodes
print(f"WARNING: {len(docs)} docs — reconsider a star relay", file=sys.stderr)
# Emit newline-delimited JSON; pipe into your provisioner or review by eye.
for d in docs:
print(json.dumps(d))
sys.exit(0)
Run python3 gen.py mesh and it prints the twelve directed documents for a four-node mesh; run python3 gen.py star and it prints six for the same nodes wired through node-a. The link_budget line on stderr makes the quadratic cost impossible to miss before you apply anything.
Comparison Table
| Axis | Star (central relay) | Full mesh | Tree / hierarchical |
|---|---|---|---|
| Links | N |
N(N-1)/2 |
N-1 |
Directed _replicator docs |
2N |
N(N-1) |
2(N-1) |
| Growth | linear | quadratic | linear |
| Conflict surface | one authority | every peer | each parent node |
| Single point of failure | yes (the relay) | no | each internal node |
| Max propagation hops | 2 | 1 | 2·log_b(N) |
| Best fit | uniform writes, read-mostly centre | small N, low-latency peer sync |
regional fan-out, constrained links |
Rule of thumb: start with a star and only add direct peer links where a specific pair needs one-hop latency that the relay cannot provide. A partial mesh layered on a star buys targeted latency without paying the full
N(N-1)bill.
Gotchas & Edge Cases
- A mesh past a handful of nodes overruns the scheduler. At
N = 20a full mesh is 380 directed continuous jobs; concentrated on one node that blows past the defaultmax_jobs. Plan the collapse to a star before you hit it, per Scaling Peer-to-Peer Sync Beyond Three Nodes. - The relay’s conflict authority is real work, not a formality. Funnelling every write through one node concentrates conflict resolution there. Size that node for the merge load, not just the storage.
- An unscoped mesh replicates everything to everyone. Without a
selectororfilterper edge, every peer stores every document and every divergence becomes a fleet-wide conflict. Scope each link by device group; the setup mechanics are in Setting Up Peer-to-Peer Sync Topologies. - Tree topologies add hops but also add lag. A three-tier tree can delay a write’s visibility at a distant leaf by several
intervalcycles. Reserve trees for regional aggregation where that lag is acceptable. - NAT quietly forbids the symmetric mesh. If edges cannot accept inbound connections, a true mesh is impossible; you are forced toward a relay the edges dial out to.
Verification & Observability
After applying a topology, confirm the graph you intended is the graph that exists. Count the jobs and confirm each reaches running:
# how many replication jobs is this node actually running?
curl -s http://localhost:5984/_scheduler/jobs | \
python3 -c "import sys,json;d=json.load(sys.stdin);print('jobs:',d['total_rows'])"
# any edge stuck in crashing/pending instead of running?
curl -s http://localhost:5984/_scheduler/jobs | \
python3 -c "import sys,json;[print(j['id'],j['state']) for j in json.load(sys.stdin)['jobs'] if j['state']!='running']"
For a star, the job count on a leaf should be small and constant as the fleet grows; if it scales with N, someone wired peer links by accident. For a mesh, the per-node count should equal N-1 if you distributed outbound documents evenly — a node running far more than that is carrying links it should have handed off. Watch the conflict rate too: a spike after wiring a new mesh edge means the link is unscoped and is replicating documents it should filter.
FAQ
Is a star topology a single point of failure that makes it unsafe?
The relay is a single point of failure for convergence, not for availability. If the relay goes down, every leaf keeps serving local reads and writes; only cross-node propagation pauses until the relay returns, at which point the queued _changes drain and the fleet re-converges. If you cannot tolerate a convergence pause, run the relay as a multi-node CouchDB cluster so no single machine is the authority, or add targeted peer links for the pairs that must never wait.
At what fleet size should I stop using a full mesh?
Watch the directed document count, N(N-1). Around N = 6 you are at 30 continuous jobs, still comfortable; by N = 15 you are at 210, and by N = 20 at 380, which crowds the default scheduler budget on any node that concentrates them. Treat roughly N = 8 to N = 10 as the practical ceiling for a full mesh and collapse to a star or a partial mesh beyond it.
Can I mix topologies in one deployment?
Yes, and it is usually the right answer at scale. A common pattern is a star for the bulk of the fleet plus a few direct peer links between the specific nodes that need one-hop latency, or a tree of regional relays each anchoring a local star. Because every edge is just a _replicator document, you compose topologies freely — the generator script above emits documents for each sub-graph and you apply them together.
Related
- Setting Up Peer-to-Peer Sync Topologies
- Scaling Peer-to-Peer Sync Beyond Three Nodes
- Conflict Generation Models
Part of: Sync Topology Models