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:

Star (central relay) versus full mesh replication graphs at five nodes Two node graphs. The left panel is a star: a central relay at the centre with five edge nodes around it, each joined to the relay by a single bidirectional link, five links in all and no direct edge-to-edge links. The right panel is a full mesh: five peers in a ring with every pair joined by a bidirectional link, ten links in all. The star scales linearly at N links and 2N directed replicator documents; the mesh scales quadratically at N(N-1)/2 links and N(N-1) directed documents. Same five nodes, two wirings — the edge count is the whole story Star (central relay) central relay E1 E2 E3 E4 E5 N = 5 → 5 links, 10 directed docs grows linearly: 2N documents Full mesh (every peer) M1 M2 M3 M4 M5 N = 5 → 10 links, 20 directed docs grows quadratically: N(N-1) documents
The same five nodes wired as a star (left) and a full mesh (right). The star adds one link per node; the mesh adds a link for every pair, so its document count climbs as 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 is N = 1 for a given database; one database replicated to ten devices is N = 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.

  1. Compute the link budget for each candidate. For a star, links = N and documents = 2N. For a full mesh, links = N(N-1)/2 and documents = N(N-1). For a balanced tree of branching factor b, links = N-1. Tabulate all three for your N; if the mesh document count exceeds a few hundred you have already found your answer.

  2. 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.

  3. 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.

  4. 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.

  5. Price the operational cost. Documents to provision and monitor scale as 2N (star), N(N-1) (mesh), or 2(N-1) (tree). WAN egress scales the same way because each link is an independent _changes stream. 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 = 20 a full mesh is 380 directed continuous jobs; concentrated on one node that blows past the default max_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 selector or filter per 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 interval cycles. 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.

Part of: Sync Topology Models