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:

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

Share this project:

Updates

Submission history