There's a small cluster of open-source apps that promise to let you message people without cell service, wifi, or a server anyone controls, using nothing but the radios already in your phone. They bounce encrypted messages phone-to-phone over Bluetooth, or hold onto them in a dead-drop until someone can pick them up. I'd seen several of them mentioned around enough times that I wanted to actually read the code instead of the marketing copy: Briar, Knit, and bitchat, which ships as two sibling implementations. One is Swift for iOS/macOS, one is Kotlin for Android, and they share a wire protocol but not always the same behavior. I also spent some hands-on time with Columba, an Android client for the Reticulum/LXMF stack, which takes a fundamentally different architectural approach from the other four and gets its own section below rather than a code read-through.
To be clear about scope: this is about apps that turn an ordinary phone's BLE radio into the mesh, not about dedicated LoRa hardware mesh networks like Meshtastic. Meshtastic solves a different problem (long-range radio relay between purpose-built nodes, phone optional) and deserves its own comparison rather than getting lumped in with "install an app and go."
They're all solving the same problem: off-grid messaging without central infrastructure. But the actual engineering underneath is surprisingly different. Here's what I found going through background execution, offline delivery, mesh transport, and crypto in each.
Quick reference #
| Briar | Knit | bitchat (iOS) | bitchat-android | Columba (Reticulum/LXMF) |
|---|
| Transport model | Pairwise, trust-graph (BLE, LAN, Tor, USB plugins) | Flood mesh (BLE + Wi-Fi Aware) | Flood mesh (BLE only) | Flood mesh (BLE + Wi-Fi Aware) | Reticulum routed mesh (BLE tested; also does TCP, LoRa/packet radio) |
| Offline delivery | Self-hosted Tor "mailbox" dead-drop | DB-backed store + anti-entropy digest sync | Sender outbox + physical/virtual couriers + Nostr | In-memory cache + Nostr fallback | LXMF propagation nodes (opt-in store-and-forward) |
| Handshake | BQP (Curve25519 DH + commitment) | X3DH-style (3x X25519 DH) | Noise XX | Noise XX | X25519 ECDH + Ed25519 identity (Reticulum Link) |
| Forward secrecy | Per-transport-time-period key rotation | Per-epoch ratchet (~200 msgs/24h) | Per-message (live), weaker offline | Per-message (live) | Per-link (ephemeral, renegotiated per Link) |
| Platform | Android + desktop headless | Android only | iOS/macOS | Android | Android |
Off-grid doesn't have to mean mesh #
Worth getting this out of the way first, since it shapes everything else. These four split into two transport models for the same underlying goal.
Knit and both bitchats are genuine flood/relay meshes. Your phone forwards other people's traffic, hopping messages across strangers' radios until they reach their target. Briar isn't built that way. Every connection it makes, whether Bluetooth, local wifi, Tor, or even a USB drive, goes directly to a specific contact you've already added, and nothing relays through a stranger's device. Bluetooth and LAN plugins handle contacts who are physically nearby without needing any internet path at all; Tor is there for contacts who aren't, giving Briar reach without ever hopping through a stranger's radio, plus a gossip mechanism for forum and group content that spreads only across your existing mutual contacts. Both are legitimate answers to off-grid messaging without a server; they just make opposite trade-offs between reaching strangers and keeping the trust circle small.
Staying alive in the background #
Nobody's found a loophole around Android's rules here. Briar, Knit, bitchat-android, and Columba all converge on the same shape: a foreground Service, a persistent low-priority notification, and a prompt asking the user to exempt the app from battery optimization. The differentiation is in how smart each one is about when to actually burn radio power scanning, and in Columba's case, in how the service itself is structured.
- Knit has an oddly clever heuristic. Besides an inexact ~15-minute
AlarmManager heartbeat, it registers a one-shot TYPE_SIGNIFICANT_MOTION sensor trigger, on the theory that if your phone just moved, you might have walked near new peers. - bitchat-android has the most granular scheme of the four. A
PowerManager singleton resolves battery and foreground state into one of four power modes, each mapping to specific BLE scan windows, down to 1-second-on/59-second-off when backgrounded with no peers connected. - Briar mostly just polls contacts on a jittered timer, but it's the only one of the four with dedicated onboarding screens for fighting Huawei's and Xiaomi's specifically aggressive battery managers. That alone tells you how much OEM-specific pain this problem causes in practice.
- Columba runs its Reticulum stack (
ReticulumService) in its own :reticulum process, specifically so a UI-process OOM kill doesn't take the radio connections down with it, and starts the foreground notification in onCreate before onStartCommand or onBind can even run, to avoid a kill in that window. On top of START_STICKY, onDestroy explicitly calls startForegroundService again as a second-chance self-restart, a pattern its own code comments credit to Sideband. Its BLE scanner adapts scan frequency to discovery activity rather than to battery or foreground state: 5-second windows while new devices keep showing up, backing off to 30 seconds once three scans in a row turn up nothing new. Unlike the other three, it registers no boot receiver at all, so it won't come back after a device reboot until the app is opened by hand.
bitchat on iOS doesn't get any of these levers. Apple's background BLE rules are stricter across the board, so the whole story is CoreBluetooth's built-in state-restoration API (CBCentralManagerOptionRestoreIdentifierKey and friends), which lets the app rebuild its Bluetooth link state after iOS suspends and relaunches it in the background. There's no Android-style fine-grained scheduling available to reach for.
Reaching someone who isn't there right now #
This is where the four diverge the most. Two mechanisms are worth sitting with in particular: Briar's mailbox and bitchat's courier system.
A Briar mailbox is small self-hosted infrastructure, like a spare phone or a cheap VPS, running as a Tor hidden service that a user sets up themselves as a drop-box contacts can leave messages in. The implementation is neat: it's literally just another SimplexPlugin, reusing the same FilePlugin base class as Briar's removable-USB-drive transport. Each contact gets an isolated inbox/outbox folder pair and a private auth token that grants folder access only, never decrypt or sign capability. So the mailbox operator sees rough timing and blob sizes and nothing else. It can go offline and stall your delivery, but it can't read or forge anything.
bitchat's courier mechanism solves the same "recipient isn't reachable right now" problem in a completely different way. Any nearby phone running bitchat can carry a sealed, opaque envelope on behalf of someone it might later run into, with a spray-and-wait copy budget (starting at up to 8 copies, halved on each handoff) that substitutes redundancy across strangers for Briar's one-chosen-relay reliability. It's a genuinely inventive idea: it can deliver between two devices that were never online at the same time, with zero setup required. But the project's own docs (docs/PEER-ID-ROTATION.md) admit a real flaw. The day-rotating tag couriers use to figure out who an envelope is for is an HMAC over the recipient's public key, and that key gets broadcast in cleartext in every announce packet. So any passive listener, not just an actual courier, can precompute and correlate a peer's tags across days. Content integrity in both systems is equally solid; a tampered message just fails to decrypt in either design, with no silent corruption. The real difference is that Briar's relay is infrastructure you specifically chose, while bitchat's is an anonymous population of strangers' phones, and that choice is exactly what determines how contained the metadata leakage stays.
Durability varies a lot too. Knit's forward_store (Room + SQLCipher) and bitchat-iOS's outbox both persist to encrypted disk and survive a restart. bitchat-android's StoreForwardManager currently caches everything in plain ConcurrentHashMaps, so force-closing the app wipes out whatever was queued. Knit's redelivery mechanism is the most sophisticated of the four: instead of re-flooding cached messages at a newly-seen neighbor, it exchanges a short digest of held message IDs and only sends the delta. That's an actual anti-entropy sync rather than a blind re-push, and it means a mesh that's already converged does close to zero extra work.
Would any of this hold up in a crowd? #
None of these projects publish load-test numbers, so take this section as reasoning from the code and known radio constraints, not a measured result.
The real ceiling is almost certainly Bluetooth's connection-count limit, not any relay algorithm. A BLE central can only hold a handful of GATT connections open at once (bitchat-android's code caps it explicitly at 8), so a crowd of a few hundred phones fragments into many small, overlapping clusters no matter how clever the software is. That's the actual reason multi-hop relay exists at all.
At small scale, a handful to a few dozen devices in one cluster, Knit, bitchat-iOS, and bitchat-android should all behave comparably. There isn't enough redundant chatter yet for their different rebroadcast-suppression strategies to matter. They diverge more in the tens-to-hundreds range, a regime all three explicitly designed for. bitchat-android's own decay curve names both a ≤10 and a >100 peer threshold directly in the code.
- bitchat-iOS uses the cleanest approach on paper. Each node relays to a deterministic, hash-seeded subset of size
⌈log₂(neighbor count)⌉ rather than all its neighbors, so rebroadcasts scale logarithmically instead of linearly. - Knit cancels a pending relay if it overhears enough neighbors already relaying the same message within a jitter window. It's self-limiting, but without the same clean asymptotic bound.
- bitchat-android just dials the relay probability down as peer count climbs, from 1.0 at ≤10 peers decaying to 0.4 past 100. It's the crudest of the three, but also the cheapest to compute and reason about.
Wi-Fi Aware (used by Knit and bitchat-android, not iOS) helps throughput for a given exchange, which is useful for attachments. But its data-path negotiation is one-active-exchange-at-a-time in Knit's implementation, so it doesn't raise how many simultaneous peers a device usefully meshes with. Whichever relay algorithm wins on paper, my bet is that the actual bottleneck at real crowd scale is BLE connection fragmentation, a limit common to all three, well before any of these suppression strategies start mattering.
Briar sidesteps most of this class of problem, since it never relays through a stranger's radio in the first place. It isn't entirely immune to BLE's connection-count ceiling though: polling many contacts over its Bluetooth plugin in one place still competes for the same handful of GATT connection slots as the flood meshes, it just doesn't get worse as strangers pile in the way a flood mesh does. Its real scaling limits are the number of contacts one device polls and how much load a mailbox server can take, both ordinary infrastructure problems on top of that baseline radio constraint, rather than radio-physics problems on their own.
Columba doesn't fit cleanly into this comparison, and I still don't have a load-test basis to rank it. But it's worth being precise about what I do and don't have the code for: Columba's own repo doesn't contain this logic at all. The flood/path-discovery behavior lives in reticulum-kt, a separate dependency it pulls in for the actual Reticulum Transport implementation. Reading that code, its announce rebroadcast control isn't neighbor-count-aware the way the other three's are at all. A node only re-forwards an announce when the hop count actually increased from what it already has on file, i.e. a genuinely new or longer path; if it sees the same hop count again (bouncing back on a shared medium), it counts that as a "local rebroadcast" and gives up after 2 (LOCAL_REBROADCASTS_MAX), with a random jitter window before forwarding at all (PATHFINDER_RW, 0.5s) and a hard 128-hop ceiling (PATHFINDER_M). That's a path-vector-style dedup, not a crowd-density throttle, and these constants are a straight port of upstream Reticulum's Transport.py, not something Columba or its dependency invented for BLE specifically. Whether that translates to sane behavior on a BLE link shared by a few hundred strangers is a real open question I can't answer from source alone, since nothing here is tuned to BLE's connection-count ceiling the way bitchat-android's decay curve explicitly is.
Security, briefly #
All four get the primitives right: modern AEAD ciphers, X25519/Curve25519 agreement, Ed25519 signatures, nothing home-rolled. Where they differ is forward-secrecy granularity, which turns out to be a spectrum shaped by each project's delivery model rather than a simple better/worse ranking.
- bitchat's live BLE sessions use a from-scratch Noise XX implementation with hourly/10k-message rekeying, giving per-message forward secrecy while both sides are actually connected.
- Knit deliberately implements an epoch ratchet instead of a full Double Ratchet, rotating every ~200 messages or 24 hours. Its own design docs explain why: a chained root would leave permanent, unrecoverable gaps once store-and-forward eviction discards an entire epoch's worth of cached frames, so each epoch derives independently off a static session root instead.
- Briar rotates transport-level keys per time period, tied to each transport's expected latency plus a 24-hour clock-skew allowance. That's forward secrecy at session/transport granularity rather than per-message.
- bitchat's offline/courier path is the weak point of the bunch by the project's own admission. It uses one-way Noise X with no forward secrecy in the documented baseline, though the shipped code actually implements an opportunistic one-time-prekey upgrade that the whitepaper still calls "future work" when a verified prekey bundle happens to be cached. It's a rare case of the implementation being ahead of the docs instead of the usual reverse.
Every one of those forward-secrecy bullets is describing private messages, and it's worth being explicit that this is a distinct question from whether a message is encrypted at all. I've since gone and built a wire-compatible bitchat client from scratch (reverse-engineering the actual binary protocol byte-by-byte, not the whitepaper prose), and one thing that surprised me: bitchat's default experience, the public mesh chat every nearby stranger sees by just opening the app, is completely unencrypted. The wire payload for a public message or announce packet is raw UTF-8 with no cipher applied at all; the only cryptographic ingredient is an Ed25519 signature over the packet, which authenticates who sent it, not who can read it. Anyone with a BLE radio in listening range can read every public message and every announce (nickname, static Noise/signing public keys) without decrypting anything, on both iOS and Android. None of the forward-secrecy machinery above touches this path at all, it only ever applies once you drop into a 1:1 DM and a Noise XX handshake completes. That's a reasonable design choice for a public local chat, but it's easy to read "Noise XX, per-message forward secrecy" in a comparison table and assume it covers the whole app rather than just its private-messaging half.
Two more things are worth flagging, since they're the kind of detail you only find by reading code instead of README files. First, bitchat's own whitepaper is refreshingly blunt that "metadata is the weakest part of this design, and the peer ID does not help." The sender ID is derived from a static key hash and never rotates, so a passive listener can track a device across locations over time; there's a drafted fix with test vectors that simply isn't shipped yet. Second, bitchat-android's README claims Argon2id for channel-password derivation, but the actual code uses PBKDF2-HMAC-SHA256 at 100k iterations. That correctly matches iOS, it just doesn't match what the README says it does. Worth checking the code, not the marketing, before trusting any specific claim from a project like this.
A third thing, also only visible from the code: bitchat's Nostr fallback path (used to reach a mutually-favorited contact over the internet when there's no BLE path between you) labels its encryption "NIP-44 v2" on the wire, but it isn't the standard NIP-44 that the rest of the Nostr ecosystem implements. The published NIP-44 spec calls for ChaCha20 plus a separate HMAC-SHA256 authentication step, with the plaintext padded into fixed length buckets specifically to stop message-length fingerprinting. bitchat-android's implementation (NostrCrypto.kt) instead runs XChaCha20-Poly1305 as a single AEAD call, with no plaintext padding whatsoever, and its own code comments admit it: "Match iOS: derive HKDF input from the compressed shared point," describing a key-derivation step that isn't in the public spec either. Functionally this still gets you confidentiality and authenticity between two bitchat installs, but it means bitchat's Nostr DMs are opaque to (and unreadable by) any standard Nostr client, and they skip the length-hiding padding a spec-compliant NIP-44 implementation would apply. If part of the appeal of the Nostr fallback is "falls back to the wider Nostr network," that's not quite what's happening; it falls back to Nostr relays as a transport, but only other bitchat clients can actually read what gets sent.
A fifth data point: Columba and Reticulum/LXMF #
Everything above came from reading source. This section didn't; it's from actually trying to use Columba, an Android messaging client built on Reticulum (a general-purpose cryptographic networking stack) and LXMF (the message format and store-and-forward layer that rides on top of it). Worth being upfront that the confidence level here is lower than a code read, this is what happened when I actually tried it, not an audit of why.
Reticulum is architecturally the odd one out of the five. It's interface-agnostic by design: the same Reticulum identity and the same LXMF message can move over TCP/IP, LoRa or packet radio, or BLE, and the stack picks whatever interfaces are configured rather than being purpose-built around one radio. That's a genuinely different model from the other four, which are BLE-first mesh apps that bolt on a secondary transport (Wi-Fi Aware, Nostr) as a supplement. Reticulum treats the radio layer as a plugin, which is powerful, but it also means the thing you're testing isn't always the thing you think you're testing.
That showed up directly when I tried to test BLE specifically. I disabled every other interface in Columba's settings to force BLE-only operation, and it still found a path to a contact through what had to be a TCP/network interface underlay, since there is no realistic scenario where 8 intermediate multihop BLE nodes exist in my area. Reticulum's transport layer will happily route over whatever interface actually has a path, config intent aside, so a BLE-only test can end up silently riding a Wi-Fi or local network link instead. That's a real usability trap for anyone trying to evaluate BLE mesh performance specifically rather than Reticulum's networking as a whole.
Two other things stood out. First, there's no global or public chat the way bitchat has its unencrypted public mesh channel or Briar has forums; Columba's model is pairwise LXMF messaging and propagation-node store-and-forward, not a broadcast room. That's a legitimate design choice, not a bug, but it's a real functional gap if what you want is bitchat's "open the app, see what's nearby" experience. Second, when I did try a genuine BLE-only path, it simply couldn't find my other node after 5 minutes, and I gave up. I can't say from the outside whether that's a BLE interface immaturity issue, a discovery/announce-interval problem, or something specific to my test setup, but it meant I couldn't get a real BLE mesh test to complete at all, which is itself the finding.
Net: on the metric this post actually cares about, phone-to-phone BLE messaging, Columba's BLE story isn't in the same place as the four purpose-built BLE mesh apps above. No public chat, a BLE-only test that didn't stay BLE-only, and a discovery that just didn't complete in five minutes of trying. Don't go in expecting a bitchat or Knit equivalent.
Where that leaves things #
Briar is the most institutionally mature of the four I read in full. Dagger DI, a database migration chain running to nearly 50 versions, and reproducible Docker builds so published APKs can be verified against source all point the same way: it gets there by deliberately keeping its trust surface small, pairwise contacts only, no ad-hoc relay through strangers. Knit is the newest and, in some ways, the most cryptographically deliberate, with real thought put into the specific hard problem of combining forward secrecy with storage that has to evict old messages. bitchat's two versions are the most ambitious in scope. Physical message-carrying couriers are a genuinely novel idea, and they're paired with the most publicly candid self-assessment of the group, admitted weaknesses included. None of these are better or worse versions of the same app. They're four different, defensible answers to what off-grid messaging should trade away. Columba, on the BLE experience I actually tested, isn't a fifth answer in that same league: no global chat, a "BLE-only" test that quietly rode a TCP path instead, and a node discovery that just never completed.
Published