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.
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.
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.
PacketHistoryFlooding 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.
FloodingRouterOn 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_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).
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.
// 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.)
// 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.
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.
// 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).
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.
Two mechanisms try to limit flood amplification:
// 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.
// 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.
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.
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.
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:
(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.
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.
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.
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.
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.
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).
| 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 |