WISP — messages that travel without a network
What inspired this
The brief asked us to build "resilient information systems for network blackouts" — infrastructure that keeps people informed when the internet, cellular, and the power grid are unreliable. We started by asking what actually fails during a blackout.
It's not just connectivity. It's coordination.
When a flood, fire, blackout, or political shutdown takes down the network, what breaks isn't only the technical link — it's the trust pathway between strangers who suddenly need to share life-safety information. "Where can I get insulin?" "Which roads are flooded?" "Has anyone seen my brother?" These questions used to be answered by neighbors knocking on doors and pinning notes to community boards. The internet replaced that with a frictionless single point of failure.
We wanted to build something that could be deployed in 30 seconds during a crisis — no app store, no sideloading, no install friction. Something that worked when WhatsApp and Twitter were dark. Something a stranger could hand to another stranger via a QR code on the back of a flyer, and it would just work.
The closest precedents we studied were Briar, Meshtastic, and the broader literature on Secure Scuttlebutt and gossip protocols. They're brilliant systems. They're also hard to deploy in an actual emergency: Briar requires sideloading, Meshtastic requires LoRa hardware, Scuttlebutt is for sustained communities, not five-minute crises. We wanted the deployability of a URL with the resilience of a mesh.
So we built WISP — a Progressive Web App that does peer-to-peer messaging without any server, account, or internet connection, using whatever transport is available: local Wi-Fi, camera-to-camera QR scanning, or printed posters.
How we built it
WISP is a React + TypeScript Progressive Web App with three independent transport tiers, all riding the same gossip-style sync engine.
Architecture at a glance
The three transport tiers
Tier 1 — WebRTC over local Wi-Fi. Two devices on the same network exchange a WebRTC offer/answer through QR codes (no STUN/TURN servers, no internet needed). Once connected, the data channel carries the gossip protocol. Works on any phone hotspot.
Tier 2 — Camera-to-camera QR sync. True no-network mode. One device gzip-compresses up to 50 messages and displays them as an animated multi-frame QR. The other device scans frame by frame and reassembles. Works in airplane mode.
Tier 3 — Printed dead-drop posters. A user generates a community bundle as a poster-sized QR code and prints it. Anyone can scan the poster and ingest the messages — even days later, even if the original poster has moved on. This is essentially a sneakernet — packets carried by paper.
The sync engine
Every message has a content-addressed ID:
$$\text{id} = \text{SHA-256}(\text{canonicalize}(\text{message}))_{[0:32]}$$
To sync efficiently, devices exchange compact Bloom-filter fingerprints rather than full message lists. With $n$ messages, $m$ filter bits, and $k$ hash functions, the false-positive rate is approximately:
$$P_{\text{fp}} \approx \left(1 - e^{-kn/m}\right)^k$$
We use $m = 8192$ bits and $k = 4$. For up to ~500 messages — our LRU cap — this gives a false-positive rate well under 1%, which means a peer occasionally fails to receive a message they didn't yet have. Since we sync repeatedly, the gap is always closed on the next exchange.
Every message that propagates accumulates a chain of custody — a sequence of
signed HopStamps where each relayer signs:
$$\text{stamp}i = \text{Sign}{\text{relayer}i}(\text{messageId} \mathbin\Vert \text{stamp}{i-1} \mathbin\Vert \text{receivedAt}_i)$$
This makes it possible for any user to verify the path a message took without trusting any individual relay. Tampering anywhere in the chain breaks verification at that hop and downstream.
Identity and trust
There are no accounts in WISP. On first launch, the app generates an
Ed25519 keypair locally and stores it in IndexedDB. The user's display name
is a daily-rotating alias derived from their public key:
So a user might be amber-wolf-17 today and slate-fox-42 tomorrow. The
public key is stable; the alias rotates. This is trust on first use in
its purest form.
We made one principled exception. News posts can be anonymous, but alerts must be signed. Anonymity in alerts creates a fake-emergency vector ("hospital is dangerous, evacuate") — but anonymity in news posts protects people sharing dissent. We split the policy along that axis intentionally.
The visual system
We rejected the obvious choice of a Signal-clone visual language and built something deliberately calmer: warm dark backgrounds, monospace UI typography, a single muted-amber accent, and alert red used nowhere except for alerts themselves. This makes alerts visually dominant by virtue of being the only color in the room — exactly the hierarchy the use case demands.
What we learned
Constraint-driven design beats feature-driven design
Every meaningful decision in WISP came from a single question: can someone deploy this in 30 seconds during a crisis with no help? That constraint killed every "nice to have" — accounts, profiles, social graph, settings, preferences — and forced us to confront only the irreducible problem.
When we considered native Bluetooth mesh, we found Web Bluetooth has zero iOS support and Chrome can only act as a client. Native apps like Briar do real BLE mesh, but they require sideloading. Sideloading violates the 30-second rule. So we accepted the trade: slower handshakes via QR, in exchange for instant deployment via URL. That's the kind of decision that only becomes obvious once you really commit to the constraint.
WebRTC is more capable than its reputation suggests
WebRTC is usually framed as a video-call stack. But it's really a generic
encrypted P2P data layer that happens to ship with every browser. By
configuring iceServers: [] and trickle: false, we got a peer connection
whose entire SDP fits in a single QR code — no signaling server, no STUN,
no infrastructure. This isn't widely known and we think more apps should
do it.
Bloom filters are still the right tool for this job
Modern systems often reach for fancier probabilistic structures (HyperLogLog, Cuckoo filters, etc.), but for sync sets in the hundreds-to-low-thousands range, a vanilla Bloom filter remains efficient, simple, and trivially serializable. Sometimes the 1970 algorithm is the right algorithm.
Identity rotation is more usable than identity selection
We considered letting users pick a username on first launch. We didn't. The daily-rotating alias is better in every way that matters: it removes a friction step, it provides plausible deniability for sensitive content, and it makes pseudonym persistence a deliberate act (sharing your handle) rather than a default. Removing a feature improved both usability and privacy.
Battery is dominated by radios, not by code
We agonized briefly over whether to do crypto in Rust + WASM for performance.
Then we measured. Ed25519 signing in pure JS via @noble/ed25519 runs in
~1 ms per signature. SHA-256 hashes in microseconds. The user-perceptible
battery cost of WISP is almost entirely the camera (during QR scans) and
the Wi-Fi radio (during sync). Optimizing the JS would have saved nothing
and cost us iOS deployability.
Print is still a transport
The day we got Tier 3 working — generate a bundle, print the poster, scan from another device — was the day the project clicked. There's something quietly profound about a hackathon project where part of the spec is "load this into a laser printer." Information moves at the speed of paper, and sometimes that's the only speed that's working.
Challenges we ran into
Synchronizing without a clock you can trust
Distributed systems usually rely on synchronized clocks for ordering. We have no such clock. Worse, devices may have wildly different times if some have been offline for days. We solved this by making message ordering content-addressed and signature-verified rather than time-ordered. Timestamps in messages are advisory; the canonical truth is the chain of hop stamps. A user knows that a message was relayed by N specific people in a specific sequence, even if no two of those people had clocks in agreement.
iOS Safari's quirks were the hardest constraint
Safari requires HTTPS for camera access, applies aggressive IndexedDB eviction to non-installed PWAs, and gathers WebRTC ICE candidates noticeably slower than Chrome. We worked around all three:
- HTTPS is provided by Vercel's free deploy.
- We surface a "Add to Home Screen" prompt on first launch — installed PWAs are exempted from storage eviction.
- We add a "generating offer..." spinner during ICE gathering so iOS users don't think the app froze.
The most painful bug took six hours to track down: Safari was returning
slightly different SDP strings on each ICE-gathering completion, causing our
QR payloads to vary even when they shouldn't have. The fix was to wait for
iceGatheringState === 'complete' before serializing rather than trusting
the signal event.
Demo-mode camera contention
During testing on a single laptop with two browser windows simulating two devices, both windows tried to open the camera simultaneously and one silently failed. We added a hidden "demo paste mode" — a textarea that accepts SDP via clipboard instead of camera — for window-to-window testing. The camera path is unchanged for real devices. Small detail, big rehearsal unblock.
Choosing what to sign and what not to
Designing the signing rules took longer than implementing them. The final rule: alerts and DMs must always be signed; news posts may be anonymous; hop stamps are always signed by the relayer. This protects against the realistic threats (impersonation, fake authority) without preventing legitimate anonymous expression. Earlier drafts were either too restrictive (blocking dissent) or too permissive (allowing fake emergency alerts to propagate freely). The signing-rule design is the part of the project we spent the most whiteboard time on, and the part we're most confident is correct.
Bloom filter false positives in a small mesh
With only 5–20 messages on a fresh device, our Bloom filter occasionally told a peer "I have all of yours" when in fact it didn't — because the filter optimizes for larger sets. The fix was simple: when a device's local message count is below ~50, skip the Bloom optimization entirely and just exchange a list of message IDs. Above that threshold, the filter wins. Below it, plain enumeration is faster and lossless.
Designing for failure modes we couldn't test
We could simulate two-device sync. We could simulate three-device hop propagation. We couldn't simulate a real disaster — a thousand devices on a flaky hotspot with adversaries injecting tampered messages. We did the best we could with adversarial unit tests: tamper with a message in IndexedDB, try to sync, verify rejection. But we know there are failure modes we won't see until WISP is used in conditions we can't manufacture.
That's a feature of the problem, not a bug in the project.
What's next
Three directions we'd take WISP if we kept building:
- Native client for sustained operation. A mobile companion that can run in the background, perform passive BLE peer discovery, and sustain mesh presence even when the web app is closed.
- Adversarial rate limiting. Right now a malicious node could flood the mesh with junk. We'd add proof-of-work message minting and per-pubkey rate limits.
- Geographic gossip biasing. Currently every message floods to every peer. With coarse zone tagging (already implemented), a future version could prefer to relay messages within their zone of relevance, reducing bandwidth and improving signal-to-noise.
But the core thesis — messages that travel without a network — is already real. You can use WISP today, on an iPhone, with no internet, no servers, and no preparation. That alone made the build worth it.
Built in 24 hours.
Built With
- css3
- dexie
- ed25519
- framer-motion
- git
- github
- html5
- indexeddb
- javascript
- lucide
- nacl
- progressive
- qrcode
- react
- react-force-graph
- service-workers
- simple-peer
- tailwind-css
- tweetnacl
- typescript
- vercel
- vite
- vite-plugin-pwa
- web
- web-crypto-api
- webrtc
- workbox
- zustand
- zxing
Log in or sign up for Devpost to join the conversation.