← RFC

Meshtastic Routing Analysis

Scope: How Meshtastic moves packets across a LoRa mesh, where the design breaks at scale, and how it behaves in the presence of hidden nodes. All claims are cited to source in /workspace/reference/meshtastic-firmware/src/mesh/ at the commit currently checked out in the reference tree. Line numbers are from that checkout.


1. Architecture: a stack of routers

Meshtastic's routing is a class hierarchy, each layer adding behaviour:

Router  (Router.cpp)              base: encrypt/decrypt, dedup gate, send/receive
  └─ FloodingRouter               managed flood: rebroadcast + dup suppression
       └─ NextHopRouter           optional next-hop hint + intermediate retransmission
            └─ ReliableRouter     end-to-end want_ack + implicit/explicit ACK

ReliableRouter is the concrete router instantiated on the device. Because of the inheritance chain, every packet passes through the flooding logic; next-hop and reliability are layered on top, not alternatives to it. This is the single most important structural fact about Meshtastic: it is a managed flooding network first, with a best-effort unicast hint bolted on.

Packet header

A meshtastic_MeshPacket carries from, to, id, hop_limit, hop_start, want_ack, next_hop, and relay_node (see MeshTypes.h / the protobuf). to == 0xFFFFFFFF is broadcast (isBroadcast()), used for channel messages and most telemetry. hop_limit defaults to 3 on the default channel.


2. Duplicate detection — PacketHistory

Flooding only works if nodes suppress packets they have already seen. Meshtastic keys this on the (sender, id) pair, not on payload content.

PacketHistory::wasSeenRecently() (PacketHistory.h:69, impl in PacketHistory.cpp) looks up a record via an open-addressed hash table:

// PacketHistory.cpp:214  — Fibonacci-hashed slot for (sender,id)
uint32_t PacketHistory::hashSlot(NodeNum sender, PacketId id) const {
    uint32_t h = sender ^ (id * 0x9E3779B9);
    h ^= h >> 16; h *= 0x45d9f3b; h ^= h >> 16;
    return h & hashMask;
}

find() (PacketHistory.cpp:286) returns the existing record or NULL; the table also tracks which relayers have been heard for each packet (NUM_RELAYERS = 6, PacketHistory.h:7). This relayer set is what later lets the node reason about whether it was a useful relay (see §6).

Consequence: dedup state is per packet-id. The table is finite; under a broadcast storm old entries are evicted and genuinely-old duplicates can be re-flooded. The id is a 32-bit random — collisions are rare but the capacity of the history table is the real limit.


3. The flood decision — FloodingRouter

On reception, shouldFilterReceived() decides whether to drop the packet:

// FloodingRouter.cpp:28
bool FloodingRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) {
    bool wasUpgraded = false;
    bool seenRecently = wasSeenRecently(p, true, ..., &wasUpgraded);
    ...
    if (seenRecently) { rxDupe++; ... }     // duplicate -> filter
    return Router::shouldFilterReceived(p);
}

If not filtered, the packet reaches perhapsRebroadcast(). In the next-hop subclass:

// NextHopRouter.cpp:133
if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
  if (isRebroadcaster()) {
    if (p->next_hop == NO_NEXT_HOP_PREFERENCE ||
        p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) {
        meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p);
        ...
        tosend->hop_limit--;                // bump down (NextHopRouter.cpp:157)
        ...
    }
  }
}

So the rebroadcast rule is: not for me, not from me, hops remain, I am a rebroadcaster, and (no next-hop hint OR I am the hinted next hop) → copy, decrement hop_limit, requeue.

Hop limit semantics

hop_start records the initial limit; hop_limit counts down. getHopsAway() = hop_start - hop_limit. Decrement is not unconditional: Router::shouldDecrementHopLimit() (Router.cpp:86) lets a chain of favorite ROUTER/ROUTER_LATE/CLIENT_BASE nodes forward without decrementing, to extend reach along a trusted backbone. First hop always decrements (getHopsAway(*p)==0 → return true).


4. Contention / CSMA — the only collision avoidance Meshtastic has

Meshtastic has no scheduling. Its entire hidden-node and collision story is a randomised CSMA back-off plus channel-activity detection (CAD). There are two delay regimes.

Slot time

// RadioInterface.cpp:1172  computeSlotTimeMsec()
float sumPropagationTurnaroundMACTime = 0.2 + 0.4 + 7;   // ms  (prop + Tx/Rx turnaround + MAC)
float symbolTime = pow_of_2(sf) / bw;                    // ms
return max(2.25, NUM_SYM_CAD + 0.5) * symbolTime + sumPropagationTurnaroundMACTime;

With NUM_SYM_CAD = 2. For the default LongFast (SF11/BW250), symbolTime = 2^11/250 = 8.192 ms, so slotTime = 2.5·8.192 + 7.6 = 28.1 ms. (Other presets: LongMod 48.6 ms, LongSlow 89.5 ms, MediumFast 12.7 ms, ShortFast 8.9 ms.)

Locally-originated traffic

// RadioInterface.cpp:644  getTxDelayMsec()
float channelUtil = airTime->channelUtilizationPercent();
uint8_t CWsize = map(channelUtil, 0, 100, CWmin /*3*/, CWmax /*8*/);
return random(0, pow_of_2(CWsize)) * slotTimeMsec;

The contention window grows with measured channel utilization: at idle CW=3 → delay ∈ [0, 8)·28 ms ≈ 0–196 ms; at saturation CW=8 → [0, 256)·28 ms ≈ 0–7.2 s.

Rebroadcast traffic — SNR-weighted

For flood rebroadcasts the delay is weighted by the SNR of the received copy (getCWsize(), RadioInterface.cpp:656; getTxDelayMsecWeighted(), :680):

// SNR mapped -20..+10 dB -> CW 3..8
uint8_t CWsize = getCWsize(p->rx_snr);
// non-router:
delay = (2 * CWmax * slotTimeMsec) + random(0, pow_of_2(CWsize)) * slotTimeMsec;

The intent: a node that heard the original weakly (low SNR, likely far away / at the edge) gets a small CW and rebroadcasts sooner, while a node that heard it strongly (close, redundant) waits longer and is likely cancelled. This is a deliberate, clever heuristic to push floods outward and suppress redundant near rebroadcasts. ROUTER-role nodes get a shorter offset so the backbone relays first.

Channel-activity gate before TX

// RadioLibInterface.cpp:392
if (isChannelActive()) { startReceive(); setTransmitDelay(); }  // busy -> back off
else { txp = txQueue.dequeue(); startSend(txp); }               // clear -> transmit

CAD before every transmit. This is real LBT, but only as good as CAD's ~2-symbol detection window can see — it cannot detect a transmitter it cannot hear (the hidden node).


5. Airtime accounting and duty cycle

Airtime per packet (getPacketTime(), RadioInterface.cpp:615) is delegated to RadioLib's getTimeOnAir() / calculateTimeOnAir() (RadioLibInterface.h:176), i.e. the standard Semtech LoRa ToA formula. Computed values for Meshtastic's 16-symbol preamble (CRC on, explicit header) at LongFast SF11/BW250/CR5:

Payload Time on air
16 B 354 ms
32 B 477 ms
64 B 723 ms
128 B 1215 ms
237 B 2034 ms

Channel utilization is tracked over a sliding 60 s window (airtime.cpp:103, channelUtilizationPercent() = Σ(six 10-s bins)/60000·100); TX utilization over a 1-hour window (utilizationTXPercent()). The duty-cycle guard (Router.cpp:321) aborts the send with DUTY_CYCLE_LIMIT when hourly TX% exceeds the regional limit (e.g. EU 868 = 10% for routers, lower otherwise; US = effectively unlimited).

Key point: these are self-protective throttles, not coordination. They cap how much one node injects; they do nothing to prevent two hidden nodes from colliding at a third.


6. Implicit ACK and rebroadcast cancellation — the scaling band-aids

Two mechanisms try to limit flood amplification:

(a) Hearing your own packet relayed → stop retransmitting

// ReliableRouter.cpp:39  shouldFilterReceived()
if (p->from == getNodeNum()) {                 // someone rebroadcast our packet
    auto old = findPendingPacket(GlobalPacketId(getFrom(p), p->id));
    if (old) {
        sendAckNak(... NONE ...);              // implicit ACK to local sender
        if (p->transport_mechanism == TRANSPORT_LORA) stopRetransmission(key);
    }
}

The sender treats a neighbour's rebroadcast as proof of progress and stops its own reliable retransmissions. Saves airtime, but it is an optimistic signal — the relay may fail downstream.

(b) Hearing someone else relay a packet you were about to relay → cancel

// FloodingRouter.cpp:138  perhapsCancelDupe()
if (p->transport_mechanism == TRANSPORT_LORA && roleAllowsCancelingDupe(p)) {
    if (Router::cancelSending(p->from, p->id)) txRelayCanceled++;
}

A CLIENT that still has a copy queued for rebroadcast drops it if it hears another node do the job first. Role-gated (roleAllowsCancelingDupe(), FloodingRouter.cpp:118): ROUTER and ROUTER_LATE never cancel (always rebroadcast); CLIENT always cancels; CLIENT_BASE cancels unless the packet involves a favorited node. ROUTER_LATE instead delays its rebroadcast into a "late window" (clampToLateRebroadcastWindow(), RadioLibInterface.cpp:464) so it only fires if nobody else did.

Reliability constants

NUM_RELIABLE_RETX = 3 (originator), NUM_INTERMEDIATE_RETX = 2 (relay) (NextHopRouter.h:91-93). Retransmission timeout (getRetransmissionMsec(), RadioInterface.cpp:627):

timeout = 2·packetAirtime + (2^CWsize + 2·CWmax + 2^((CWmax+CWmin)/2))·slotTime + 4500 ms

The PROCESSING_TIME_MSEC = 4500 term dominates at small payloads — retransmissions are deliberately slow to avoid piling onto a congested channel.


7. Next-hop routing — a weak learned overlay

NextHopRouter adds a single-byte next-hop hint per destination, learned passively:

// NextHopRouter.cpp:192  getNextHop()
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(to);
if (node && node->next_hop && node->next_hop != relay_node) return node->next_hop;
return std::nullopt;        // -> fall back to flooding

next_hop is just the last byte of a NodeNum (getLastByteOfNodeNum). It is learned from observing ACKs/replies flow back (sniffReceived(), NextHopRouter.cpp:89): when an ACK for our forwarded packet comes back via relay X, we record X as the next hop toward the original source. Limitations:

So even "directed" Meshtastic traffic is flooding with a suppression hint, not source or table routing.


8. Where it breaks at scale

8.1 Broadcast-storm quadratic load

Every broadcast is rebroadcast by (ideally) every rebroadcaster that hears it and has hops left. In a mesh where the flood reaches all N nodes, one originated broadcast costs up to N transmissions. If every node originates one message per interval T, channel load is:

L ≈ N · (1 + R) · ToA / T,   R = rebroadcasters reached ≈ N-1
  ≈ N² · ToA / T

Why N² — the intuition (for readers meeting this formula for the first time). The load is two independent linear effects multiplied together:

  1. More nodes ⇒ more originators. N nodes each sending every T seconds offer N/T messages/s — one factor of N.
  2. Flooding ⇒ each message is rebroadcast by ~every node. One originated message costs ~N transmissions (originator + ~N−1 relays) — the second factor of N.

(N originations) × (N transmissions each) × ToA seconds of airtime ÷ T = N²·ToA/T. The practical consequence: doubling the network roughly quadruples the load, so a flooded data plane falls off a cliff at a specific node count rather than degrading gracefully. Rearranging L = util for the node count where the channel saturates gives N_sat = √(util·T/ToA).

At LongFast (32 B ⇒ ToA = 0.477 s), the channel saturates (L → 1.0) at:

N_sat = sqrt(util · T / ToA)
T (per-node msg interval) util=1.0 (theoretical) util=0.37 (CSMA-good) util=0.18 (ALOHA-like)
30 s ~7.9 nodes ~4.8 ~3.4
60 s ~11 nodes ~6.8 ~4.8
300 s ~25 nodes ~15 ~11

This is the quantitative core of "flood collapse": at one message per node per minute, a realistic LongFast channel is saturated somewhere between ~7 and ~11 nodes — long before the hundreds the project targets. The SNR-weighted CW and dupe-cancel push the constant factor down (fewer than N rebroadcasters actually fire), but the N² scaling is structural and no constant fixes it.

8.2 hop_limit = 3 is a blunt instrument

The default 3-hop limit both protects the network (caps flood radius) and breaks it: geometries needing 4+ hops simply never deliver, and there is no mechanism to discover that a longer path exists. Raising hop_limit multiplies storm load (§8.1) network-wide.

8.3 No congestion feedback loop that actually sheds load

CW grows with utilization, but under a true storm the channel is already lost: back-off just spreads the same N² transmissions over more time, increasing latency without restoring delivery. The duty-cycle guard sheds a node's own originations, not relays.

8.4 Next-hop overlay degrades exactly when needed

Learned next-hops depend on stable return traffic. In high-churn / mobile scenarios the hints are stale or absent, so traffic falls back to flooding precisely when the network is busiest.


9. Hidden-node behaviour

Meshtastic's defenses against the hidden-node problem are entirely the CSMA/CAD layer of §4, and they are structurally unable to solve it:

The practical result, widely reported in the community ("Meshtastic at scale" failure reports), is that adding an elevated router that hears everyone increases hidden-node collisions, because it pulls together transmitters that would otherwise never have contended.


10. What to borrow / what to reject for Loomwave

Borrow: - SNR-weighted rebroadcast back-off (getTxDelayMsecWeighted) — cheap, stateless, and genuinely reduces redundant relays. A good default for discovery/flood traffic. - Dupe-cancel + implicit ACK (perhapsCancelDupe, ReliableRouter) — the right instinct: treat overheard relays as progress and suppress redundant work. - Role-gated rebroadcast (roleAllowsCancelingDupe) — matches our asymmetric-role mandate; ROUTER/INFRA should relay deterministically, CLIENTs should suppress. - Self-protective airtime/duty accounting (airtime.cpp, duty-cycle guard) — keep, as a floor under any MAC.

Reject / replace: - Managed flooding as the data-plane default. The N² load (§8.1) is disqualifying at the target scale. Flooding should be confined to discovery (neighbor/route adverts), not data — exactly the "hybrid" the brief proposes. - Single-byte last-byte next-hop hints. Too ambiguous and too fragile to be a routing substrate. We need real addressing + a path/route table (see Reticulum and MeshCore analyses). - CSMA-only hidden-node handling. Must be supplemented by INFRA-coordinated scheduling/LBT (see hidden-node-analysis.md and mac-layer-options.md).


Source index (files cited)

Topic File:symbol
Dedup hash / relayer set PacketHistory.cpp:hashSlot:214, :find:286, PacketHistory.h:69
Flood filter FloodingRouter.cpp:shouldFilterReceived:28
Rebroadcast decision NextHopRouter.cpp:perhapsRebroadcast:133
Hop-limit decrement policy Router.cpp:shouldDecrementHopLimit:86
Slot time RadioInterface.cpp:computeSlotTimeMsec:1172
Origin CSMA delay RadioInterface.cpp:getTxDelayMsec:644
SNR-weighted rebroadcast delay RadioInterface.cpp:getTxDelayMsecWeighted:680, getCWsize:656
CAD before TX RadioLibInterface.cpp:onNotify:392
Airtime RadioInterface.cpp:getPacketTime:615, RadioLibInterface.h:computePacketTime:176
Utilization / duty cycle airtime.cpp:channelUtilizationPercent:103, Router.cpp:321
Implicit ACK / stop retx ReliableRouter.cpp:shouldFilterReceived:39
Dupe cancel FloodingRouter.cpp:perhapsCancelDupe:138, roleAllowsCancelingDupe:118
Next-hop hint NextHopRouter.cpp:getNextHop:192, sniffReceived:89, send:23
Retx constants/timeout NextHopRouter.h:91, RadioInterface.cpp:getRetransmissionMsec:627