Integrating Indoor Maps with React Native SDKs

This page covers the one integration step that React Native makes uniquely hard — moving a precompiled indoor routing graph from a network payload into a native rendering and routing engine without freezing the UI thread — and it sits inside the SDK Integration Patterns workflow, immediately after version negotiation has decided which floor-level artifact the device should hold.

What “Integrating an Indoor Map into React Native” Means

In a React Native indoor app, the map is never a JavaScript component. Rasterising tiles, indexing a directed routing graph of thousands of nodes, and running A* on every position update are all O(V + E) workloads that would block the single JS thread and stall touch handling. The integration is therefore a bridge contract: a native module (a UIView/MKMapView subclass on iOS, a MapView/GLSurfaceView on Android, wrapping a C++ or Rust routing core) owns the graph and the render surface, and the JS layer only sends commands and receives events.

“Hydration” is the load-bearing term. It means deserialising a validated GeoJSON FeatureCollection into the native engine’s internal adjacency structures, building it in a staging slot, and promoting the engine reference only on success — so a half-parsed graph never serves a query. The graph itself is already trusted: it passed the contract defined in JSON Schema Design for Indoor Maps and the gates in CI Gating for Map Updates before it was ever published, and its geometry is expressed in the building’s local Indoor Coordinate Reference System. The bridge re-validates only a thin envelope, then hydrates.

Minimal Working Example: Gate the Envelope Before Hydration

Before any bytes cross the bridge, confirm the payload is the canonical envelope the native engine expects: a GeoJSON FeatureCollection with a metadata block carrying map_version, schema_revision, floor_level, and a content-addressed topology_hash. Rejecting a bad envelope here turns a silent on-device “no route” into a loud, logged failure at the boundary.

import logging
from typing import Any

from pydantic import BaseModel, Field, ValidationError, field_validator

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

SUPPORTED_REVISIONS: frozenset[str] = frozenset({"v1.2.0", "v1.3.0"})


class EnvelopeMetadata(BaseModel):
    map_version: str
    schema_revision: str
    floor_level: int
    topology_hash: str = Field(..., min_length=8)
    coordinate_system: str

    @field_validator("schema_revision")
    @classmethod
    def supported(cls, v: str) -> str:
        if v not in SUPPORTED_REVISIONS:
            raise ValueError(f"unsupported schema_revision: {v}")
        return v


class MapEnvelope(BaseModel):
    type: str = Field(..., pattern="^FeatureCollection$")
    metadata: EnvelopeMetadata
    features: list[dict[str, Any]]


def gate_envelope(raw: dict[str, Any]) -> MapEnvelope:
    """Validate a GeoJSON map envelope before it is handed to the native bridge."""
    try:
        env = MapEnvelope(**raw)
    except ValidationError as exc:
        logger.error("envelope rejected before hydration: %s", exc)
        raise
    logger.info("envelope ok: floor=%d hash=%s", env.metadata.floor_level, env.metadata.topology_hash[:12])
    return env

The Native Bridge: Hydrating Off the JS Thread

The JS side of the bridge does four things in order: send a conditional fetch keyed on the topology_hash it already holds, stage the returned graph natively, promote on success, and surface routing through a thin async facade. The native side does the expensive work. Promote-on-success is what keeps an in-progress route alive: the live engine is replaced only when a complete new one exists.

import { NativeModules } from "react-native";

interface WayfindingEngine {
  topologyHash: string;
  computeRoute: (start: string, end: string) => Promise<string[]>;
}

export async function syncEngine(
  endpoint: string,
  floorLevel: number,
  current?: WayfindingEngine,
): Promise<WayfindingEngine> {
  const headers: Record<string, string> = { "X-Schema-Revision": "v1.3.0" };
  if (current) headers["If-None-Match"] = current.topologyHash; // negotiate, do not re-download

  const res = await fetch(`${endpoint}/maps/${floorLevel}/latest`, { headers });
  if (res.status === 304 && current) return current;            // already current
  if (res.status === 409) throw new Error("SCHEMA_INCOMPATIBLE: client build is stale");
  if (!res.ok) throw new Error(`map fetch failed: ${res.status}`);

  const envelope = await res.json();
  // Build into a native staging slot; a half-loaded graph must never serve a query.
  const staged: boolean = await NativeModules.WayfindingBridge.loadGraph(envelope);
  if (!staged) throw new Error("native hydration failed");

  return {
    topologyHash: envelope.metadata.topology_hash,
    computeRoute: (s, e) => NativeModules.WayfindingBridge.computePath(s, e),
  };
}

NativeModules.WayfindingBridge.loadGraph resolves a promise from the native thread (or a TurboModule on the New Architecture), so the parse never touches the JS event loop. State changes — position updates, POI highlights, a recompute onto a new node — flow back the other way through a NativeEventEmitter, never as synchronous return values.

React Native indoor-map bridge: hydrate off the JS thread, promote on success The JS thread holds the single event loop. syncEngine issues a conditional GET (If-None-Match set to the topology_hash it already holds); a 304 keeps the current engine, a 409 surfaces SCHEMA_INCOMPATIBLE, and a 200 yields a GeoJSON envelope. That envelope crosses the native bridge as a loadGraph(envelope) promise. On the native thread, stage 2 validates only the thin envelope (FeatureCollection type, metadata block, supported schema_revision), stage 3 builds the adjacency and spatial index into a staging slot that never serves a live query, and stage 4 atomically promotes the engine reference on success and resolves staged = true back to the JS thread, which then exposes a computeRoute async facade. Throughout navigation, a NativeEventEmitter pushes position and route-state events upward into the JS event handlers, never as synchronous return values, while A-star path search and tile rasterisation remain on the native thread and never touch the JS event loop. React Native indoor-map bridge: hydrate off the JS thread, promote on success JS THREAD · single event loop NATIVE THREAD · TurboModule / C++ routing core NATIVE BRIDGE · async, serialized 1 · syncEngine — conditional GET GET /maps/{floor}/latest If-None-Match: topology_hash 304 keep · 409 incompatible · 200 → envelope JS event handlers onPositionUpdate(...) onRouteState(...) async only — never sync returns 5 · Promote local ref live engine reference swapped on success expose computeRoute(start, end) thin async facade → ordered node ids 2 · Validate thin envelope type = FeatureCollection metadata present schema_revision supported 3 · Build routing graph adjacency + spatial index into a staging slot never serves a live query 4 · Promote engine ref atomic swap once staged graph is complete resolve loadGraph → staged = true half-built graph is never promoted loadGraph(envelope) ↓ staged = true ↑ NativeEventEmitter ↑ position · route-state A* path search + tile rasterisation stay on this thread — they never touch the JS event loop

Coordinate Calibration: Aligning the Native Canvas

CAD-exported floor plans arrive in millimetre-scale engineering coordinates; the native render surface expects the building’s bounded local Cartesian frame. Apply a 2D affine transform built from at least three non-collinear control points before the geometry is serialised into the envelope, so every device renders and routes in one consistent reference frame and the topology_hash stays stable across rebuilds.

import logging

import numpy as np

logger = logging.getLogger(__name__)


def compute_affine(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
    """Solve a 3x3 affine matrix mapping CAD coordinates (src) to the SDK canvas (dst)."""
    if src.shape[0] < 3:
        raise ValueError("at least 3 non-collinear control points are required")
    a = np.hstack([src, np.ones((src.shape[0], 1))])
    try:
        mx, *_ = np.linalg.lstsq(a, dst[:, 0], rcond=None)
        my, *_ = np.linalg.lstsq(a, dst[:, 1], rcond=None)
    except np.linalg.LinAlgError as exc:
        logger.error("affine solve failed (degenerate control points?): %s", exc)
        raise
    matrix = np.vstack([mx, my, [0.0, 0.0, 1.0]])
    logger.info("affine residual scale=%.4f", float(matrix[0, 0]))
    return matrix

Export the resulting calibrated extent as viewport_bounds on the envelope so the native tile loader, pinch-to-zoom handler, and routing core all share one bounded frame.

Bridge Contract Reference

Field / call Type Default Notes
metadata.topology_hash string (≥8) Content-addressed graph identity; sent as If-None-Match for negotiation
metadata.schema_revision string Must be in the device’s supported set or the middleware returns 409
metadata.floor_level int Z encodes the floor level; one engine per active/adjacent floor
X-Schema-Revision (request header) string The build’s max supported revision; drives the compatibility check
loadGraph(envelope) Promise<boolean> Builds the routing graph in a native staging slot; resolves true on success
computePath(start, end) Promise<string[]> A* over the hydrated graph on the native thread; returns ordered node ids
viewport_bounds [minX, minY, maxX, maxY] Calibrated extent from the affine transform; bounds tiles and zoom

Common Errors & Fixes

No route found on a fully mapped floor. The device build predates the artifact’s schema_revision, so it deserialised the envelope without error but cannot read the renamed edge properties. Diagnose by comparing the X-Schema-Revision request header against the artifact’s schema_revision; fix it by negotiating at the middleware and returning 409 SCHEMA_INCOMPATIBLE rather than letting the client guess:

if (res.status === 409) {
  // Surface an "update required" state — never hydrate a graph the build cannot parse.
  throw new Error("SCHEMA_INCOMPATIBLE: prompt app update");
}

NSInvalidArgumentException / ClassCastException during deserialisation. The native parser hit a field type it did not expect (a coordinate sent as a string, or a null where a number was required). This is the symptom of skipping the envelope gate. Run gate_envelope at the boundary so a malformed payload is rejected before it crosses the bridge, not after the native thread has already thrown.

Blue dot teleports after a background refresh. The engine reference was swapped while a route was active. Stage the new graph and promote it only at a safe decision point — when the user reaches the next node — so the in-progress route finishes on the graph it started on and reconciles to the new topology_hash at the next junction.

Integration Point

This page is one stage of a longer delivery path. The envelope it hydrates is produced and signed off upstream by JSON Schema Design for Indoor Maps and the merge gates in CI Gating for Map Updates; the topology_hash the bridge sends as If-None-Match is the same content-addressed tag that drives Cache Invalidation Strategies, so the device and the edge agree on identity for free. When hydration or positioning fails, the bridge hands control to the degradation ladder described in Fallback Routing Architectures, and the degradation_stage telemetry it emits is what lets Rollback Triggers & Versioning auto-revert a release that is failing in the field.

Frequently Asked Questions

Why hydrate on the native thread instead of parsing in JavaScript?

Parsing a large floor-level routing graph and indexing it for A* is an O(V + E) workload; on React Native’s single JS thread it blocks touch handling and freezes the blue dot. Resolving loadGraph from a native promise (or a TurboModule) keeps the parse and every subsequent route computation off the event loop, so the UI stays responsive even while a new floor hydrates.

What is the minimum the bridge should re-validate on-device?

Only the thin envelope — type is FeatureCollection, the metadata block is present, and schema_revision is in the device’s supported set. Deep topology validation already ran once at the gate before promotion, so re-running it on the phone wastes battery and duplicates work. The device’s only deeper check is that the topology_hash it hydrated matches the one it requested.

How do I avoid corrupting an in-progress route when a new map publishes?

Never replace the live engine reference mid-step. Build the new graph in a native staging slot and promote it only at a decision point such as the next node. The active route continues on the graph it started with and reconciles to the new topology_hash at the next junction, so the position never jumps onto an edge that no longer exists.

This page is part of the SDK Integration Patterns guide within the Production-Ready Indoor Map Deployment reference.