Available for new projects & full-time roles

Let's Talk
All posts
How I Built an Offline-First Event Platform Using BLE Mesh Networking
2025-11-15·22 min read·technical

How I Built an Offline-First Event Platform Using BLE Mesh Networking

How I designed a production-grade offline-first event management app using BLE mesh networking, Kalman filtering, and hybrid transport layers, without any internet dependency.

BLE mesh
React Native
offline-first
JavaScript
mobile engineering
Bluetooth networking

Most apps assume the internet exists. I built one that doesn't.

At Mediusware, I'm leading development of OurStoryz, an event guest management platform designed to work in environments where Wi-Fi is unreliable, cellular is congested, and every device in the room is fighting for bandwidth. Think large weddings, festival grounds, closed-campus events. Exactly the environments where every other app breaks down.

The core problem: how do you enable real-time communication and location tracking between hundreds of guests using only the devices already in their pockets?

The answer was BLE mesh.

TL;DR: I built OurStoryz, a production event platform where devices form a peer-to-peer BLE mesh with no central server: each node runs dual Central/Peripheral roles, RSSI readings are smoothed with a Kalman filter and converted to positions via trilateration, and messages hop across a hybrid transport (BLE, Wi-Fi Direct, Multipeer Connectivity) selected by payload size and channel health. Everything is end-to-end encrypted with ECDH/AES-256-GCM and queued locally in Realm DB so nothing is lost during handovers. In a 300-person test venue it delivered messages in ~180ms average with zero internet dependency.


The Architecture

flowchart LR A[Device A] -- BLE --> B[Device B] B -- BLE --> C[Device C] A -- Wi-Fi Direct --> D[Device D] B -- BLE --> E[Device E] B -- Wi-Fi Direct --> F[Device F] C -- BLE --> G[Device G] F -- Multipeer --> G E -- BLE --> D subgraph local["Every Node"] direction TB R[(Realm DB\nlocal)] end B --- local

Each device runs in a dual Central/Peripheral BLE role simultaneously. It broadcasts its own presence while scanning for nearby peers. There's no central server in the loop, devices discover each other directly and form a mesh where messages can hop peer-to-peer across the network.

Dual-Role BLE: The Implementation

The key insight is that iOS and Android both support concurrent advertising and scanning, but the API surface is different enough that you need a clean abstraction layer:

typescript
interface BLENodeConfig {
  deviceId: string;
  serviceUUID: string;
  characteristicUUID: string;
  maxHops: number;
  ttl: number; // time-to-live in seconds
}

class DualRoleBLENode {
  private centralManager: CentralManager;
  private peripheralManager: PeripheralManager;
  private routingTable: Map<string, RouteEntry> = new Map();
  private messageCache: Set<string> = new Set(); // dedup by message ID

  async startMesh(config: BLENodeConfig): Promise<void> {
    // Start advertising OUR presence as peripheral
    await this.peripheralManager.startAdvertising({
      serviceUUIDs: [config.serviceUUID],
      localName: config.deviceId,
    });

    // Start scanning for OTHER nodes as central
    this.centralManager.startScanning({
      serviceUUIDs: [config.serviceUUID],
      allowDuplicates: false,
      onDiscover: (peripheral) => this.onPeerDiscovered(peripheral),
    });
  }

  private onPeerDiscovered(peripheral: Peripheral): void {
    // Update routing table with fresh RSSI reading
    this.routingTable.set(peripheral.id, {
      peripheral,
      rssi: peripheral.rssi,
      lastSeen: Date.now(),
      smoothedRssi: this.kalmanFilter(peripheral.id, peripheral.rssi),
    });
  }

  async routeMessage(msg: MeshMessage): Promise<void> {
    if (this.messageCache.has(msg.id)) return; // already seen, drop
    this.messageCache.add(msg.id);

    if (msg.targetId === this.config.deviceId) {
      this.onMessageReceived(msg);
      return;
    }

    if (msg.hopCount >= this.config.maxHops) return; // TTL expired

    const nextHop = this.selectNextHop(msg.targetId);
    if (nextHop) {
      await this.sendToPeer(nextHop, { ...msg, hopCount: msg.hopCount + 1 });
    }
  }
}

RSSI Smoothing With Kalman Filtering

BLE in a crowded event space is brutal. Signal noise is everywhere. A single RSSI reading from a nearby device can swing 15 dBm between measurements due to multipath interference, body shadowing, and competing RF sources. Raw RSSI is useless for positioning.

I implemented a scalar Kalman filter per tracked device. The state is the estimated true signal strength; the observation is the raw RSSI reading. The filter separates signal from noise:

typescript
interface KalmanState {
  x: number;  // estimated signal (dBm)
  p: number;  // estimation error covariance
}

const Q = 0.008; // process noise: how fast the true signal can change
const R = 2.5;   // measurement noise: how noisy the sensor is

class RSSIKalmanFilter {
  private states: Map<string, KalmanState> = new Map();

  update(deviceId: string, measurement: number): number {
    if (!this.states.has(deviceId)) {
      this.states.set(deviceId, { x: measurement, p: 1.0 });
      return measurement;
    }

    const { x, p } = this.states.get(deviceId)!;

    // Predict step: propagate uncertainty forward
    const p_pred = p + Q;

    // Update step: incorporate new measurement
    const K = p_pred / (p_pred + R); // Kalman gain
    const x_new = x + K * (measurement - x);
    const p_new = (1 - K) * p_pred;

    this.states.set(deviceId, { x: x_new, p: p_new });
    return x_new;
  }
}

With Q = 0.008 and R = 2.5, the filter heavily weights prior estimates over noisy new readings. This gives stable position estimates even in RF-hostile environments. The values were tuned empirically in a 300-person test environment.

Trilateration: From Signal to Position

With smoothed RSSI values from three or more reference nodes, we can estimate position. The RSSI-to-distance conversion uses the log-distance path loss model:

typescript
const PATH_LOSS_EXPONENT = 2.8; // empirically tuned; 2.0 = free space
const REFERENCE_RSSI = -59;     // RSSI at 1 metre (device-specific calibration)

function rssiToDistance(rssi: number): number {
  return Math.pow(10, (REFERENCE_RSSI - rssi) / (10 * PATH_LOSS_EXPONENT));
}

interface Anchor {
  x: number;
  y: number;
  distance: number; // metres, from rssiToDistance()
}

// Non-linear least squares solver (Gauss-Newton, 2 iterations is enough for events)
function trilaterate(anchors: Anchor[]): { x: number; y: number } | null {
  if (anchors.length < 3) return null;

  let x = 0, y = 0; // initial estimate: centroid of anchors
  anchors.forEach(a => { x += a.x; y += a.y; });
  x /= anchors.length;
  y /= anchors.length;

  for (let iter = 0; iter < 10; iter++) {
    let sumFx = 0, sumFy = 0, sumF2 = 0;
    let Jx = 0, Jy = 0;

    for (const a of anchors) {
      const dx = x - a.x;
      const dy = y - a.y;
      const est = Math.sqrt(dx * dx + dy * dy);
      const residual = est - a.distance;
      if (est < 1e-9) continue;
      sumFx += residual * (dx / est);
      sumFy += residual * (dy / est);
      sumF2 += residual * residual;
      Jx += (dx * dx) / (est * est);
      Jy += (dy * dy) / (est * est);
    }

    const denom = Jx + Jy;
    if (Math.abs(denom) < 1e-9) break;
    x -= sumFx / denom;
    y -= sumFy / denom;
  }

  return { x, y };
}

In practice, event venues are 2D enough that this converges in 2–3 iterations. The position error is ±2–4 metres, sufficient for "zone-level" tracking (near stage, near bar, near exit) without GPS.

The Transport Layer

BLE alone isn't enough. I built a hybrid transport system that routes communication across three channels depending on what's available:

Priority Transport Characteristics
1 BLE Always available, low energy, short range
2 Wi-Fi Direct When negotiated, 250 Mbps, 200m range
3 Apple Multipeer Connectivity iOS fallback, ~100m

Selection logic: capability negotiation during BLE handshake

typescript
type Transport = "ble" | "wifi-direct" | "multipeer";

interface TransportCapability {
  ble: boolean;
  wifiDirect: boolean;
  multipeer: boolean; // iOS only
}

class HybridTransport {
  private activeTransport: Transport = "ble";
  private transportHealth: Record<Transport, number> = {
    "ble": 1, "wifi-direct": 0, "multipeer": 0
  };

  selectTransport(
    localCaps: TransportCapability,
    remoteCaps: TransportCapability,
    payloadSize: number
  ): Transport {
    const canUseWifi = localCaps.wifiDirect && remoteCaps.wifiDirect;
    const canUseMultipeer = localCaps.multipeer && remoteCaps.multipeer;

    // Large payloads (photos, venue maps) prefer Wi-Fi Direct
    if (payloadSize > 50_000 && canUseWifi && this.transportHealth["wifi-direct"] > 0.5) {
      return "wifi-direct";
    }

    // BLE for everything under 512 bytes (control messages, positions, pings)
    if (payloadSize <= 512 && this.transportHealth["ble"] > 0.5) {
      return "ble";
    }

    if (canUseMultipeer && this.transportHealth["multipeer"] > 0.5) {
      return "multipeer";
    }

    return "ble"; // always fall back to BLE
  }

  reportFailure(transport: Transport): void {
    // Exponential decay on failures
    this.transportHealth[transport] *= 0.5;
    if (this.transportHealth[transport] < 0.1) {
      // Silence that transport for 30 seconds
      setTimeout(() => { this.transportHealth[transport] = 0.5; }, 30_000);
    }
  }
}

If a guest's device loses one channel, the system silently promotes the next available one. Messages queue locally in Realm DB with conflict resolution logic, so nothing is lost during handovers.

Offline Queue: Realm DB

Every outgoing message, regardless of transport, goes through a persistent queue:

typescript
class MessageSchema extends Realm.Object {
  _id!: string;
  payload!: string;          // JSON-serialized, AES-encrypted
  targetId!: string;
  transport!: string;
  queuedAt!: number;
  attempts!: number;
  maxAttempts!: number;
  status!: "pending" | "sent" | "failed";

  static schema: Realm.ObjectSchema = {
    name: "Message",
    primaryKey: "_id",
    properties: {
      _id: "string",
      payload: "string",
      targetId: "string",
      transport: "string",
      queuedAt: "int",
      attempts: { type: "int", default: 0 },
      maxAttempts: { type: "int", default: 5 },
      status: { type: "string", default: "pending" },
    },
  };
}

// The retry worker runs every 5 seconds when the app is foregrounded
async function processQueue(realm: Realm): Promise<void> {
  const pending = realm.objects<MessageSchema>("Message")
    .filtered("status == 'pending' AND attempts < maxAttempts");

  for (const msg of pending) {
    try {
      await transmit(JSON.parse(msg.payload), msg.transport as Transport);
      realm.write(() => { msg.status = "sent"; });
    } catch {
      realm.write(() => {
        msg.attempts += 1;
        if (msg.attempts >= msg.maxAttempts) msg.status = "failed";
      });
    }
  }
}

The queue survives app restarts. Messages with attempts >= maxAttempts are marked failed and surfaced to the user ("couldn't reach that guest").

Security

This platform handles guest identity and private communications. The encryption stack:

  • AES-256-GCM for message encryption (authenticated encryption, tampering is detectable)
  • ECDH on P-256 for peer-to-peer key exchange (no preshared secrets)
  • Keys are device-ephemeral and rotated per session
typescript
import { webcrypto } from "crypto";
const subtle = webcrypto.subtle;

async function deriveSharedKey(
  localPrivateKey: CryptoKey,
  remotePubKeyBytes: ArrayBuffer
): Promise<CryptoKey> {
  const remotePubKey = await subtle.importKey(
    "raw", remotePubKeyBytes,
    { name: "ECDH", namedCurve: "P-256" },
    false, []
  );

  const rawShared = await subtle.deriveBits(
    { name: "ECDH", public: remotePubKey },
    localPrivateKey, 256
  );

  // HKDF to derive a proper AES key from the raw shared secret
  const hkdfKey = await subtle.importKey("raw", rawShared, "HKDF", false, ["deriveKey"]);
  return subtle.deriveKey(
    { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: new Uint8Array() },
    hkdfKey,
    { name: "AES-GCM", length: 256 },
    false, ["encrypt", "decrypt"]
  );
}

async function encryptMessage(key: CryptoKey, plaintext: string): Promise<ArrayBuffer> {
  const iv = webcrypto.getRandomValues(new Uint8Array(12)); // 96-bit nonce
  const encoded = new TextEncoder().encode(plaintext);
  const ciphertext = await subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
  // Prepend IV to ciphertext for transport
  const result = new Uint8Array(12 + ciphertext.byteLength);
  result.set(iv, 0);
  result.set(new Uint8Array(ciphertext), 12);
  return result.buffer;
}

Every message is end-to-end encrypted across every transport layer. Whether it hops over BLE or Wi-Fi Direct, the payload is opaque to any relay node.

What This Taught Me

Most engineers don't touch this layer of the stack. BLE mesh isn't in any bootcamp curriculum. The closest reference material is academic papers on sensor networks, Zigbee mesh specifications, and robotics SLAM literature. I pulled from distributed systems theory (CAP theorem applies to mesh networks too), RF signal processing, and mobile OS internals simultaneously.

The performance results in a 300-person closed-venue test:

Metric Target Achieved
Message delivery latency < 500ms ~180ms avg
Position update rate 2 Hz 2.1 Hz
Queue drain after outage < 10s 6.8s avg
Battery overhead < 8% ~5.5%
Encryption overhead < 5ms ~1.8ms

The result: a production platform that works fully offline, handles dense guest crowds, and maintains encrypted communication regardless of network state.

If you're building anything in the offline-first, edge computing, or BLE space, this is the problem set I've lived inside.


Working on something that needs to survive without the internet?

BLE mesh, offline-first mobile, edge computing, these are niche problem sets that most engineers haven't shipped in production. I have. If your product needs to work in RF-hostile environments, handle peer-to-peer communication, or maintain state across unreliable networks, let's talk.

Frequently asked

Does this work on both iOS and Android?

Yes. Both platforms support concurrent BLE advertising and scanning (dual Central/Peripheral role), but the API surface is different enough to require an abstraction layer. The hybrid transport logic handles iOS-specific Multipeer Connectivity alongside the cross-platform BLE and Wi-Fi Direct layers.

What's the practical range of the mesh?

BLE range is roughly 10-30m per hop depending on environment. Wi-Fi Direct extends this to ~200m for paired devices. Because messages can hop peer-to-peer across the mesh, a 300-person venue is fully covered even if no two devices are directly in range: the network finds a route.

How do you handle messages sent while a device is outside the mesh?

Every outgoing message is persisted in a local Realm DB queue before transmission. If the device is out of range or the transport fails, the retry worker picks up pending messages every 5 seconds when the app is foregrounded. Messages with 5 failed attempts are marked failed and surfaced to the user.

Can you build something like this for my project?

Yes. BLE mesh, offline-first architecture, and hybrid transport systems are core work I've shipped in production. If your product needs to operate in RF-hostile or connectivity-uncertain environments, reach out and we can scope what the architecture looks like for your use case.

Hiring or have a project?

Let's build something that holds.

Full-stack engineering, system design, and legacy modernization. Available for freelance, contract, and full-time roles.

Keep reading