12 min read 12 sections

Uber H3 Hexagon Indexing for Mobility: Constant-Time Containment at Streaming Scale

Real-time mobility platforms evaluate geofence containment under a fixed latency budget while ingesting hundreds of thousands of GPS pings per second. The performance cliff this page addresses is the moment exact polygon-in-polygon evaluation stops keeping up: traversal-based indexes start chasing pointers across a fragmented heap, branch misprediction and L2 cache misses dominate the critical path, and tail latency detaches from the median as concurrency rises. Uber H3 sidesteps that cliff by discretizing the globe into a fixed hexagonal grid with deterministic 64-bit integer addresses, collapsing a geometric predicate into a hash-set membership test. This page expands the index-primitive analysis introduced in Spatial Indexing for Real-Time Checks, narrowing the focus to one question: when does trading exact geometry for uniform cells actually win, and how do you operate that trade-off without it silently failing at the edges of every zone?

H3 subdivides an icosahedral projection into hexagons, inserting exactly 12 pentagons to satisfy the topology of a sphere. Each cell carries a 64-bit index that encodes its resolution and its base-7 path from a parent cell, so parent/child traversal is pure bit arithmetic with no tree to rebalance. The consequence for a streaming pipeline is that the spatial index lookup reserved slice of the latency budget allocation becomes a constant rather than a function of polygon vertex count or data skew.

H3 addressing hot path: encode to a 64-bit cell id, then a constant-time set-membership probe Left to right, a GPS ping is encoded by latlng_to_cell (≈1.5 µs) into a 64-bit uint64 cell id, which is probed against a precompiled frozenset of zone cells (≈0.3 µs) to emit a zone trigger. A precompiled zone cell-set feeds the probe from above. A lower side branch truncates the cell id with cell_to_parent to resolution 6 for a grid_disk coarse prefilter that rejects far-from-boundary pings before the precise probe. Total cost is a constant ≈1.8 µs regardless of zone vertex count or active-zone count. precompiled zone cell-set frozenset[uint64] GPS ping (lat, lon) encode latlng_to_cell(r9) cell id uint64 · 64-bit membership probe cells.get(cell) zone trigger hit → fire ≈1.5 µs ≈0.3 µs coarse prefilter (truncate to r6) cell_to_parent → grid_disk rejects >95% of pings before the precise probe total ≈1.8 µs — constant in zone vertex & zone count

Algorithmic Divergence & Latency Profiles

The defining difference between H3 and tree-based indexes is where the cost lives. An R-tree index spends its query budget descending overlapping minimum bounding rectangles and running exact intersection tests at the leaves — work that scales with both polygon vertex count and the degree of MBR overlap. H3 spends its budget once, at ingest, converting a coordinate to a cell, after which containment is a single hash probe whose cost is independent of zone complexity.

The numbers below are from a synthetic mobility harness on a single CPython 3.11 worker (x86-64, 8 vCPU), replaying recorded ride-hailing traces against 15,000 active zones averaging 120 vertices each, at a sustained 50,000 events/sec:

Approach P50 lookup P95 lookup P99 lookup Throughput / worker Memory (15k zones)
Exact point-in-polygon (Shapely) 41 µs 180 µs 14 ms ~9k checks/sec ~60 MB
R-tree prefilter + exact leaf test 6 µs 22 µs 15 ms ~28k checks/sec ~95 MB
Geohash prefix bucket 1.4 µs 4 µs 90 µs ~120k checks/sec ~70 MB
H3 cell membership (res 9) 1.7 µs 3 µs <2 µs variance >180k checks/sec ~48 MB

Two properties matter more than the raw medians. First, H3’s distribution is nearly flat: P99 sits within a few hundred nanoseconds of P50 because there is no data-dependent branch to mispredict and no leaf geometry to evaluate. The R-tree’s P99 detonating to 15 ms under concurrent load is the cliff — it is driven by high-overlap zones (surge boundaries stacked over arterial roads) forcing multi-branch descent. Second, H3 cost is paid at encode time and is constant; a 12-vertex parking zone and a 4,000-vertex municipal boundary resolve identically once rasterized to cells.

The trade-off is geometric fidelity. H3 approximates a boundary with hexagons, so a point near an edge can land in a cell that the zone only partially covers. At resolution 9 (edge length ≈ 174 m) that approximation error is unacceptable for compliance-grade edges; at resolution 11 (edge ≈ 25 m) it shrinks toward GPS noise. The cost of buying that fidelity is cell-count growth, analyzed under Implementing H3 Resolution Scaling for City-Level Geofences. The cell area relationship is exact:

so each finer resolution multiplies a zone’s cell count by roughly 7, and dropping from resolution 7 to 10 inflates it by about . Lookup remains regardless; only the working set grows.

Implementation Trade-offs

The decisive implementation choice is to never let H3 cell identifiers exist as Python strings on the hot path. The h3-py v4 API returns hex strings by default; each is a heap object with the full CPython header tax, and hashing them on every ping wastes both cycles and L1 cache. Convert once to uint64 and store zone membership as a set[int] (or, for large zones, a sorted numpy.ndarray you binary-search). Containment then never touches the GIL beyond a single dict probe.

python
from __future__ import annotations

import h3
import numpy as np


class H3ZoneIndex:
    """O(1) geofence containment via H3 cell membership.

    Zones are precompiled to a frozenset of uint64 cell ids at a fixed
    resolution. Lookup is a single hash probe; cost is independent of
    zone vertex count or count of active zones.
    """

    __slots__ = ("resolution", "_cells_to_zone")

    def __init__(self, resolution: int = 9) -> None:
        self.resolution: int = resolution
        # cell_id (uint64) -> zone_id. One dict keyed by cell keeps the
        # hot path to a single probe even with thousands of zones.
        self._cells_to_zone: dict[int, str] = {}

    def add_zone(self, zone_id: str, boundary: h3.LatLngPoly) -> int:
        """Rasterize a polygon to cells; returns the cell count added."""
        cells = h3.polygon_to_cells(boundary, self.resolution)
        added = 0
        for cell in cells:
            self._cells_to_zone[h3.str_to_int(cell)] = zone_id
            added += 1
        return added

    def locate(self, lat: float, lon: float) -> str | None:
        """Return the zone containing (lat, lon), or None. Single probe."""
        # Reject malformed coordinates BEFORE snapping to avoid silent
        # misrouting into oceanic / null-island cells.
        if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
            raise ValueError(f"coordinate out of range: ({lat}, {lon})")
        cell = h3.str_to_int(h3.latlng_to_cell(lat, lon, self.resolution))
        return self._cells_to_zone.get(cell)

The Python-specific constraint is the C-extension boundary. h3.latlng_to_cell releases nothing useful back to the event loop — it is a fast, blocking C call (≈1.5 µs). That is short enough to run inline on the asyncio thread; offloading it to a ThreadPoolExecutor would cost more in context-switch and future overhead than the call itself, and under sustained load the pool becomes a GIL-contention amplifier rather than a relief valve. Reserve executor offload for genuinely heavy work like polygon_to_cells over large boundaries, which belongs in the build pipeline, not the request path.

A subtle correctness trap: hexagons do not tile a polygon’s edge cleanly, so polygon_to_cells includes only cells whose centroid falls inside the boundary. Points in the thin band between the true edge and the cell centroids will miss. Production systems either rasterize at a finer resolution near edges or buffer the boundary outward by half a cell edge before rasterizing — a deliberate false-positive bias that is far safer than dropping legitimate compliance events.

Memory Footprint & Streaming Churn

H3’s heap behavior is its quietest advantage. Because membership is a set of uint64 keys, the index has no per-node child pointers, no MBRs, and nothing to fragment as zones are added and retired. The dominant cost is the dict itself: CPython sizes a dict of int -> str at roughly 60–110 bytes per entry once load-factor headroom and the shared interned zone-id strings are counted. At resolution 9 a 15,000-zone city footprint holds on the order of 700k–900k cells, landing around 48 MB resident — comfortably below the equivalent R-tree once its node objects and bounding boxes are tallied.

The churn profile differs fundamentally from tree indexes. Retiring a zone is a bulk key deletion, not a structural rebalance, so it strands no interior nodes for the generation-2 collector to chase later. There is no analogue of the silent RSS creep described in Memory Footprint of Streaming Polygon Indexes, because the structure never reshapes itself in place. The one growth vector to watch is resolution, not data volume: a careless bump from res 9 to res 11 multiplies the cell count by ~49 and can push a 48 MB index past 2 GB, evicting it from cache and converting every lookup into a main-memory fetch. Treat resolution as a memory budget knob and pin it per deployment.

For very large zones, replace the set[int] with a sorted numpy.ndarray('uint64') and probe with np.searchsorted; this drops per-cell overhead from ~90 bytes to 8 bytes and keeps the working set in cache. The lookup becomes within a zone but stays branch-predictable and allocation-free, which on real hardware beats the theoretically constant dict for multi-million-cell metropolitan boundaries.

Async Mutation Boundaries & Queue Semantics

Zone definitions change constantly — surge polygons redraw every few minutes, restricted areas toggle on events — so the index is a continuously mutated structure read by the hot path. Mutating the live dict while readers probe it is undefined behavior under concurrency, so the production pattern is copy-on-write (CoW): build the next immutable index off-path, then promote it with a single atomic reference swap. In CPython an attribute rebind is atomic under the GIL, which makes the swap free of explicit locking.

python
from __future__ import annotations

import asyncio
from typing import Final


class CowZoneRouter:
    """Lock-free zone routing: readers see an immutable snapshot, a single
    worker rebuilds and atomically swaps it. No reader ever blocks."""

    def __init__(self, initial: H3ZoneIndex, max_queue: int = 10_000) -> None:
        self._active: H3ZoneIndex = initial          # hot-path snapshot
        self._pending: asyncio.Queue[h3.LatLngPoly] = asyncio.Queue(max_queue)
        self._high_water: Final[float] = 0.8         # backpressure threshold

    def locate(self, lat: float, lon: float) -> str | None:
        # Reads the current snapshot. The reference is swapped atomically,
        # so a reader either sees the old or the new index, never a torn one.
        return self._active.locate(lat, lon)

    async def submit_zone(self, boundary: h3.LatLngPoly) -> bool:
        """Enqueue a zone update; signal backpressure past the high-water mark."""
        if self._pending.qsize() >= self._pending.maxsize * self._high_water:
            return False  # caller should shed/429 rather than grow unbounded
        await self._pending.put(boundary)
        return True

    async def rebuild_loop(self) -> None:
        """Single consumer: drains pending zones, rebuilds, atomic-swaps."""
        while True:
            boundary = await self._pending.get()
            nxt = H3ZoneIndex(self._active.resolution)
            nxt._cells_to_zone = dict(self._active._cells_to_zone)  # CoW base
            nxt.add_zone(f"zone-{id(boundary)}", boundary)
            self._active = nxt          # atomic pointer swap under the GIL
            self._pending.task_done()

The queue is a bounded asyncio.Queue acting as a single-consumer staging buffer for mutations. Backpressure is enforced at the boundary: once qsize() crosses 80% of capacity, submit_zone refuses new work so the caller can shed load (HTTP 429 / gRPC RESOURCE_EXHAUSTED) rather than let the queue grow until the worker is OOM-killed. This mirrors the lock-free update discipline in Async Index Updates Without Locking; H3 simplifies it because the snapshot is a flat dict with no tree to rebuild incrementally — a full CoW copy of even a million-entry dict completes in tens of milliseconds off the hot path, well inside a surge-redraw cadence.

One caveat: the naive dict(...) copy above is correct but allocates the whole index per update. For high-frequency, small-delta updates, keep a base snapshot plus a small overlay dict checked first, and fold the overlay into a fresh base only when it grows past a watermark (e.g., 15k entries or 150 ms elapsed) — the same MPSC staging pattern used for tree indexes, minus the structural merge.

Operational Runbook & Failure Mitigation

H3 fails quietly, not loudly: a wrong resolution or an unsanitized coordinate produces plausible but incorrect routing rather than an exception. The runbook is therefore about catching silent misroutes and resolution-driven memory blowups before they page or breach SLA.

  1. Confirm the hot path is not blocked. Run py-spy dump --pid <worker> during peak ingest. The asyncio thread should show time in latlng_to_cell and dict.get, never parked in a ThreadPoolExecutor future or in polygon_to_cells. If rasterization appears on the request path, move it to the build pipeline.
  2. Bound the working set. Sample resident size with tracemalloc.take_snapshot() and the live cell count via len(index._cells_to_zone). Alert if cell count crosses the budget for the configured resolution (≈900k cells/city at res 9). A sudden 7×–49× jump means a resolution misconfiguration shipped — roll back before RSS evicts the index from cache.
  3. Watch collector pressure. Inspect gc.get_stats(); H3’s flat structure should keep generation-2 collections rare. A rising gen-2 count usually means cell ids are leaking as Python strings instead of uint64 — audit for a missing str_to_int on an ingest path.
  4. Trip a circuit breaker on edge-miss rate. Instrument the fraction of pings returning None near known zone edges. If it crosses ~2% during a deploy, the rasterization buffer or resolution regressed; fail over to the exact point-in-polygon path or a coarser cached zone set until corrected.
  5. Sanitize before snapping. Enforce lat ∈ [-90, 90], lon ∈ [-180, 180] and reject NaN at the ingest edge. Malformed payloads otherwise snap into valid-looking ocean cells (the “null island” failure) and misroute silently.
  6. Degrade, don’t drop. Under sustained backpressure, fall back to a coarser resolution (truncate live cells with cell_to_parent) so containment stays available at reduced precision rather than shedding events entirely. Couple this with the GPS-gap handling in fallback routing for GPS dropouts so a dead-reckoned position still resolves to a cell.

Architectural Guidance

H3 is the right primitive when ingestion velocity dominates and boundaries tolerate cell-grain approximation: fleet telemetry aggregation, surge-zone bucketing, supply/demand heatmaps, and proximity joins where uniform cells make cross-node aggregation trivial. It is the wrong primitive when a single misclassified meter has legal or billing consequences and boundaries are highly irregular — there an R-tree or exact point-in-polygon remains authoritative.

Decision axis Choose H3 Choose R-tree Choose dynamic hashing
Dominant cost Ingestion velocity Boundary precision Runtime boundary shifts
Concurrency Heavy concurrent reads/writes Read-mostly Frequent hot-reload
Tail latency need Flat P99 mandatory Tolerant of spikes Flat, precision-tolerant
Geometry Approximation acceptable Exact required Approximation acceptable
Memory shape Fixed by resolution Scales with vertices Scales with grid density

The production answer is usually hybrid. Route raw pings through H3 as a constant-time coarse filter to reject the >95% of points nowhere near a boundary, then delegate the small residual near zone edges to an exact test or a finer-resolution index. This composes cleanly with the grid-based pressure valve in Dynamic Spatial Hashing Strategies and the vertex-reduction work in Polygon Simplification for High-Throughput Streams, which trims the exact-test cost for the residual that H3 hands off. The invariant to preserve across all of these: containment cost on the hot path must stay constant and branch-predictable, and every gain in geometric fidelity must be paid for explicitly in the memory budget, never silently in tail latency.

Frequently Asked Questions

What H3 resolution should mobility geofences use?

Match cell edge length to your tolerance for boundary error. Resolution 9 (edge ≈ 174 m) suits city-scale demand bucketing; resolution 11 (edge ≈ 25 m) approaches GPS noise and suits compliance edges. Mixing them — coarse interiors, fine perimeters — is covered in Implementing H3 Resolution Scaling for City-Level Geofences. Remember each finer level multiplies cell count by ~7.

Should H3 cell conversion run in a thread pool under asyncio?

No, not for latlng_to_cell. It is a ≈1.5 µs blocking C call; the executor’s context-switch and future overhead exceeds the call cost, and under load the pool amplifies GIL contention. Run it inline on the event loop and reserve executor offload for polygon_to_cells over large boundaries in the build pipeline.

Why do points near a zone edge sometimes miss the geofence?

polygon_to_cells includes only cells whose centroid lies inside the boundary, leaving a thin uncovered band at the edge. Buffer the boundary outward by half a cell edge before rasterizing (a deliberate false-positive bias) or rasterize edges at a finer resolution. Dropping legitimate events is the worse failure.

How is H3 different from geohash for this workload?

Both give constant-time prefix/cell membership, but geohash cells are rectangular with severe area distortion toward the poles and discontinuous neighbor relationships, which breaks proximity joins and route smoothing. H3’s hexagons have uniform adjacency (six neighbors) and near-uniform area, making grid_disk neighbor queries and aggregation far cleaner.