~learn.
?

Day 0533 minPreface+60 XP

Location Search and Search at Scale.

8 stages7 code

watch it first · newThe whole lesson in a 24-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 2 min

Preface#

Saturday evening in Bengaluru. The e-commerce platform from this week launches "Now": essentials delivered in minutes from dark stores across the city. Within half an hour, location breaks it. (An illustrative scene: the platform, numbers and times are made up; each failure is a classic one.)

LAUNCH NIGHT, FOUR BUGS (ILLUSTRATIVE)
  • 19:58 —
    A customer in Koramangala types "milk". The answer now depends on which stores can reach her door
  • 20:03 —
    The places table has a B-tree on (lat, lng). A "within 2 km" query reads a latitude strip across the whole country, then throws most of it away
  • 20:09 —
    40,000 delivery partners send positions every 4 seconds into the product search cluster: 10,000 document updates a second. Product search p99 climbs from 120 ms to 900 ms
  • 20:14 —
    A store asks for the nearest free partner and gets "none within range". The nearest is 43 metres away, across a grid-cell edge
  • 20:31 —
    A cricket match ends. Thousands open the app in the same two square kilometres, and one location shard hits 100% CPU while the rest idle

None of these is a text-search bug. Each is about space: indexing "near", handling things that move, and everyone being in the same place.

ExplainerThe whole lesson: location search and search at scale

Day 5 opens on an illustrative launch night of four location bugs. A B-tree on (lat, lng) reads a latitude strip ~600 km wide, ~150× the box asked for, so every technique maps space to cells, covers the query and refines by distance. Geohash adds 5 bits a character, but points 43 m apart can share only tdr, so search the cell and 8 neighbours; quadtrees adapt to density, S2 numbers cells in Hilbert order, and H3 hexagons have one neighbour distance. Radius queries go cell → stores → products; for 2 km, geohash 5 reads ~12,000 candidates and H3 res 8 ~1,560 against ~720 inside; kNN grows rings until a bound proves it is done. 300,000 partners every 4 s is 75,000 writes a second, but only ~1 ping in 45 changes its res-8 cell (~1,700/s), so positions are overwritten in memory with a TTL. Hot cells need load-sized shards, per-cell caching and caps; the cluster runs three masters, 32 primaries of ~29 GB and reindexes behind an alias.

Yesterday (Day 4) built typeahead: answering a prefix of text in milliseconds because the work was done ahead of time. Today the idea comes back for places: a geohash is a string whose prefix is a region, a trick both powerful and leaky. The last part keeps the operations essentials of this week's cluster: topology, shard sizing and reindexing with aliases.

Part I · Stage 1 of 8 · 2 min

Part I: The Nearby Problem#

Chapter 1: Why "Near" Breaks a B-tree#

1.1 The Simple Definitions#

  • Point of interest (POI): something that stays put (a store, an ATM). Few writes, rich filters.
  • Moving object: a position that streams (a delivery partner, a driver). Constant writes, simple questions.
  • Radius query: everything within r of a point. k-nearest (kNN): the k closest, however far.
  • Cell: one tile of a grid over the Earth, with an ID. A covering is a set of cells that contains a shape.

1.2 Two Numbers, One Circle#

A B-tree sorts by one key. An index on (lat, lng) sorts by longitude only among rows with exactly the same latitude, which never happens. So it can use the latitude range, and the longitude test filters every row in it.

Your turn · checkpoint 1not answered

Before the answer: what does (lat, lng) actually read?

The platform also lists 20 million places in India (shops, restaurants, ATMs; an illustrative number). The query is WHERE lat BETWEEN 12.917 AND 12.953 AND lng BETWEEN 77.606 AND 77.643: a 4 km × 4 km box around the customer. Indexes: one composite B-tree on (lat, lng). Which rows does the database read, and roughly how much bigger is that than the box?

Think it through, or jot it down. Then compare.

One strong answer

  • It reads a latitude strip: every place between 12.917°N and 12.953°N, from the Arabian Sea to the Bay of Bengal, about 4 km tall and about 600 km wide. The longitude test runs on each of them.
  • Ratio: the strip is ~150× the box by area, and the box is already ~27% bigger than the circle inside it. Tens of thousands of rows to return a few hundred.
  • Two separate indexes don't fix it: you get a strip along a parallel and a strip along a meridian, and intersect two big sets.
  • The fix: give nearby places nearby keys, so "near here" becomes a few short key ranges.

1.3 The Core Idea: Cells, Coverings, Refine#

Every technique today does three steps:

  1. Map 2-D to 1-D. Number grid cells so nearby cells usually get nearby numbers (a space-filling curve). A sorted index works again.
  2. Cover the query. Turn "within 2 km" into a short list of cells or cell ranges.
  3. Refine. Compute exact distances for the candidates in those cells; throw away the corners.

The trade-off is in step 2: big cells mean few lookups and many wasted candidates, small cells the reverse. Cell size for the radius is the main tuning knob.

DiagramFilter and refine: cells give candidates, distance gives answers
%% title: Filter and refine: cells give candidates, distance gives answers
flowchart TD
  Q@{ icon: "sd:map-pin", label: "Query<br/>12.9352, 77.6245 · within 2 km" }
  COV["Cover<br/>radius → cells or cell ranges"]:::core
  IDX@{ icon: "sd:index", label: "Cell index<br/>cell id → items" }
  CAND["Candidates<br/>everything in those cells"]:::service
  REF["Refine<br/>exact great-circle distance ≤ 2 km"]:::core
  OUT(["Answer<br/>sorted by distance or score"]):::ok
  DROP(["Corners thrown away<br/>the price of square or hex cells"]):::bad
  Q ==> COV ==> IDX ==> CAND ==> REF ==> OUT
  REF -.-> DROP
  class Q client
  class IDX store

Part II · Stage 2 of 8 · 7 min

Part II: Grids of Cells#

Chapter 2: Geohash#

2.1 How a Geohash Is Built#

Geohash (Gustavo Niemeyer, 2008) halves the world again and again: the first bit says east or west of the prime meridian, the second north or south of the equator, alternating from there. The bits are written 5 at a time in base 32 (digits and lowercase letters except a, i, l, o). Every character adds 5 bits and divides the cell by 32.

pythongeohash · 23 lines
BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"   # no a, i, l, o

def geohash(lat, lng, precision=6):
    """Interleave longitude and latitude bits, 5 bits per character."""
    lat_lo, lat_hi, lng_lo, lng_hi = -90.0, 90.0, -180.0, 180.0
    bits, use_lng, out = 0, True, []
    for i in range(precision * 5):
        if use_lng:                                   # even bits: longitude
            mid = (lng_lo + lng_hi) / 2
            bit = lng >= mid
            lng_lo, lng_hi = (mid, lng_hi) if bit else (lng_lo, mid)
        else:                                         # odd bits: latitude
            mid = (lat_lo + lat_hi) / 2
            bit = lat >= mid
            lat_lo, lat_hi = (mid, lat_hi) if bit else (lat_lo, mid)
        bits = (bits << 1) | bit
        use_lng = not use_lng
        if i % 5 == 4:                                # 5 bits -> one character
            out.append(BASE32[bits])
            bits = 0
    return "".join(out)

print(geohash(12.9352, 77.6245, 6))   # tdr1w6   Koramangala

A shared prefix means a shared cell: every point in Koramangala's tdr1w6 is also in tdr1w, tdr1 and tdr. As with yesterday's typeahead, a prefix search on a sorted key is a range scan.

The size per character, from Elasticsearch's geohash grid docs (worst case, at the equator):

Length Cell (width × height) Good for
4 39.1 km × 19.5 km A metro region
5 4.9 km × 4.9 km "Within a few km"
6 1.2 km × 609.4 m A neighbourhood
7 152.9 m × 152.4 m A street block
8 38.2 m × 19 m A building

Cells shrink east to west as you leave the equator ("cell dimensions vary with latitude", in the docs' words): in Bengaluru, at 13°N, a length-6 cell is about 1.19 km × 0.61 km.

India Post's DIGIPIN uses the same recursive idea with a different alphabet: a 4 × 4 grid at each of ten levels, so each character carries 4 bits instead of 5. The DIGIPIN case file designs a whole service around it.

2.2 Neighbours and the Edge Problem#

The guarantee only runs one way. Wikipedia's summary is exact: a longer shared prefix means the points are closer, but "the reverse of this is not guaranteed, as two points can be very close but have a short or no shared prefix".

Two points in east Bengaluru, 43 metres apart on the same latitude: (12.9352, 77.6951) is tdr1xf, (12.9352, 77.6955) is tdr384. They share only tdr: they sit either side of a line where the curve jumps between quarters of the tdr cell. Across the equator or the prime meridian, neighbours share nothing. That's launch night's 20:14 bug.

The standard fix: search the point's cell plus its 8 neighbours, at a precision where a cell is at least as wide as the radius, then refine. Redis does exactly this, checking "the 1+8 areas needed to cover the whole shape and discarding elements outside it" (Redis docs). Compute neighbours with a library: cells thin out near the poles and wrap at ±180°.

Chapter 3: Quadtrees#

3.1 Splitting Where the Data Is#

A geohash grid is the same everywhere: a length-6 cell over the Thar desert is the same size as one over Koramangala. A quadtree (named by Finkel and Bentley in 1974; the version here, splitting into equal quarters, is the point-region or "bucket" variant) adapts. Start with one square; when it holds more than N points, split it into four; keep splitting the busy quarters. Dense city centres get tiny cells, empty land stays one big cell, and every leaf holds at most N items.

DiagramA quadtree splits only where the points are (leaf capacity 100, illustrative)
%% title: A quadtree splits only where the points are (leaf capacity 100, illustrative)
flowchart TD
  ROOT["India<br/>20M places · split"]:::core
  NW["North-west quarter<br/>split again"]:::service
  NE["North-east quarter<br/>80 places · leaf"]:::ok
  SW["South-west quarter<br/>split again"]:::service
  SE["South-east quarter<br/>split again"]:::service
  BLR["… Bengaluru centre<br/>split again and again"]:::hot
  KOR["Koramangala leaf, 13 levels down<br/>~400 m square · 97 places"]:::ok
  ROOT --> NW
  ROOT --> NE
  ROOT --> SW
  ROOT --> SE
  SE --> BLR
  BLR --> KOR

3.2 Static vs Dynamic#

  • Static: built from a snapshot, held in memory, replaced by a fresh build every few minutes or hours. Ideal for POIs, which change rarely. No locks, no rebalancing.
  • Dynamic: inserts and deletes in place, splitting full leaves and merging empty siblings. Needed when points move, and costly: every boundary crossing is a delete plus an insert, busy leaves churn as crowds come and go, and readers need locks or copy-on-write.

Quadtrees answer kNN naturally: walk the tree best-first, nearest node first, until the k-th result is closer than the next node. The catch: a quadtree is a data structure, not a key. You can't use "a quadtree node" in a sorted database index or as a shard key. S2 fixed that.

Chapter 4: S2 Cells and H3 Hexagons#

4.1 S2: A Quadtree You Can Number#

Google's S2 projects "the six faces of a cube onto the unit sphere" and subdivides "each cell into four children recursively": a quadtree per face, levels 0 to 30, leaf cells "about 1cm across" (S2 docs). Two choices make it database-friendly:

  • Every cell is a 64-bit S2CellId, ordered along Hilbert curves "linked together to form a single continuous loop over the entire sphere". Unlike geohash's Z-order, "if the S2CellIds of two cells are close together, then the cells are also close together".
  • A cell's descendants are one contiguous ID range (range_min() to range_max()): everything inside a cell is one range scan.

S2RegionCoverer turns a circle or polygon into a few cells at mixed levels, with max_cells as the budget: the docs report a median covering 1.98× the region's area with 8 cells, 1.42× with 20. More cells, less waste. S2 cells aren't equal-area: at level 12 they range from 3.04 km² to 6.38 km².

Uber used this in 2015. InfoQ's report of Matt Ranney's QCon London talk: "the driver's phones post location updates to the server every four seconds", Uber "chose to use Google's S2 Geometry Library" with cells of about 3.31 km² as the shard key, and wanted to "handle one million writes a second and a much higher read rate" (InfoQ, March 2015).

4.2 H3: Why Hexagons#

In 2018 Uber open-sourced H3. It projects the Earth onto an icosahedron and tiles it with hexagons, plus "twelve pentagons, one at each of the icosahedron vertices", since hexagons alone can't tile a sphere. It "supports sixteen resolutions", each with "one seventh the area" of the one above, and every index is a 64-bit integer (Uber Engineering, 2018; h3geo.org).

Why hexagons? Their neighbours. Squares have 8 neighbours at two distances, triangles 12 at three, and "for hexagons, all neighbors are equidistant" (H3 docs). Uber adds that hexagons "minimize the quantization error introduced when users move through a city"; it calculates "surge pricing by measuring supply and demand in hexagons". In practice:

  • Rings approximate circles. "Within k steps" (grid_disk) is a near-circular disc of 3k(k+1) + 1 cells (away from the 12 pentagons), so a radius search over-fetches less than a square of squares.
  • Smoothing is easy. Averaging demand over a cell and its 6 neighbours treats every neighbour the same.
  • The cost: hexagons don't nest. "Hexagons do not cleanly subdivide into seven finer hexagons": a parent's 7 children only roughly cover it, though "logical containment in the index is exact". Rolling counts up resolutions is approximate at the edges, unlike S2 or geohash.
H3 resolution Average edge Average area
7 1.406 km 5.161 km²
8 531 m 0.737 km²
9 201 m 0.105 km²

4.3 Choosing a Grid#

Your turn · checkpoint 2not answered

Pick the index for three jobs

(a) 2,000 dark stores, each with a hand-drawn delivery polygon, in PostgreSQL. (b) 300,000 partners pinging every 4 seconds, queried for "free partners within 2 km of this store". (c) An ops heat map of orders per neighbourhood, smoothed with the surrounding areas. What would you use for each, and why?

Think it through, or jot it down. Then compare.

One strong answer

  • (a) PostGIS with a GiST (R-tree) index. Shapes, small volume, rare edits, next to the data they describe.
  • (b) An in-memory cell index (H3 resolution 8 or 9, or Redis GEO). Positions overwrite constantly; a database index taking every ping is wasted work.
  • (c) H3. Equidistant neighbours make "cell plus its 6 neighbours" a fair smoothing window, what Uber built it for.
Geohash Quadtree S2 H3 R-tree (Chapter 5)
Cells Fixed rectangles, 32 children Adaptive squares, 4 children Squares on a cube, 4 children Hexagons, ~7 children Overlapping bounding boxes
Key String prefix None, it's a tree 64-bit ID, Hilbert order 64-bit ID None, it's a tree
Neighbours 8, two distances, edge jumps Irregular 8, contiguous ranges 6, one distance Not a grid
Best for Simple stores (Redis, any KV) In-memory POIs, uneven density Coverings, range scans, sharding Moving things, aggregation, rings Shapes and polygons in a database
Weakness Thin cells far from the equator, edge cases Not a key, no sharding Unequal cell areas Children don't nest exactly Updates and overlap cost

Part III · Stage 3 of 8 · 3 min

Part III: Trees in Databases and Search Engines#

Chapter 5: R-trees, PostGIS and Elasticsearch#

5.1 R-trees and PostGIS GiST#

Grids carve up space; an R-tree (Guttman, SIGMOD 1984) groups the data. Each node stores the bounding box of everything below it; boxes can overlap; a search descends only into boxes that intersect the query. It handles shapes as easily as points: a delivery polygon, a flood zone, a district boundary.

That's what PostgreSQL's spatial extension uses: "PostGIS uses an R-Tree index implemented on top of GiST", and "spatial indexes store only the bounding box of geometries" (PostGIS docs). The exact geometry test runs after the index narrows things down: filter and refine again.

sql24 lines
CREATE TABLE stores (
  id       bigint PRIMARY KEY,
  name     text,
  location geography(Point, 4326),      -- longitude first: ST_MakePoint(x, y)
  zone     geography(Polygon, 4326)     -- the delivery area, drawn by ops
);
CREATE INDEX stores_location_gix ON stores USING GIST (location);
CREATE INDEX stores_zone_gix     ON stores USING GIST (zone);

-- Radius: stores within 2 km. ST_DWithin uses the index; geography units are metres.
SELECT id, name, ST_Distance(location, ST_MakePoint(77.6245, 12.9352)::geography) AS metres
FROM stores
WHERE ST_DWithin(location, ST_MakePoint(77.6245, 12.9352)::geography, 2000)
ORDER BY metres;

-- k-nearest: the <-> operator walks the GiST index when it's in ORDER BY.
SELECT id, name
FROM stores
ORDER BY location <-> ST_MakePoint(77.6245, 12.9352)::geography
LIMIT 5;

-- The real question: which stores' delivery zones contain this customer?
SELECT id FROM stores
WHERE ST_Intersects(zone, ST_MakePoint(77.6245, 12.9352)::geography);

From the docs: ST_DWithin "includes a bounding box comparison that makes use of any indexes", and for geography "units are in meters" with use_spheroid = true by default; <-> "will make use of 2D GiST indexes" when it's in ORDER BY. A WHERE ST_Distance(...) < 2000 gives the same answer but can't use the index, so it computes a distance for every row.

The last query is the one that matters. A real delivery area isn't a circle: a river, a flyover with no exit or a one-way street turns 800 m on the map into 3 km on the road. Ops draw zones; the system answers "which zones contain this point". The Blinkit side quest precomputes this into an H3 cell → stores table so no geometry runs at request time.

5.2 Location in Elasticsearch#

This week's product index can filter by place too, with a geo_point field. Watch the order: "String geopoints are ordered as lat,lon, while array geopoints, GeoJSON and WKT are ordered as the reverse" (Elasticsearch docs); mix them up and Bengaluru lands in the Arctic, off Svalbard, without an error. These points aren't in Day 1's inverted index: since 6.0 the default structure is "the new block k-d tree (BKD)" (Elastic, 2019).

"Milk near me", with location as a filter (Day 3: filters don't score and can be cached) and distance as a tiebreaker:

json20 lines
GET products-now/_search
{
  "query": {
    "bool": {
      "must":   { "match": { "name": "milk" } },
      "filter": [
        { "term": { "in_stock": true } },
        { "geo_distance": {
            "distance": "2km",
            "store_location": { "lat": 12.9352, "lon": 77.6245 } } }
      ]
    }
  },
  "sort": [
    "_score",
    { "_geo_distance": {
        "store_location": { "lat": 12.9352, "lon": 77.6245 },
        "order": "asc", "unit": "km" } }
  ]
}

geo_distance matches "within a given distance of a geopoint", using arc by default or plane, which is "faster, but inaccurate on long distances and close to the poles". This version indexes one document per product per store, which is simple and grows fast. The usual alternative is two steps: look up which stores serve the customer's cell, then search products with a terms filter on those store IDs.

Part IV · Stage 4 of 8 · 2 min

Part IV: Radius and k-Nearest Queries#

Chapter 6: Asking the Questions#

6.1 Radius Queries End to End#

Diagram"Milk within reach": cell lookup, then product search on the stores that can deliver
%% title: "Milk within reach": cell lookup, then product search on the stores that can deliver
sequenceDiagram
  autonumber
  participant A as Customer app
  participant S as Search API
  participant Z as Zone index, cell to stores
  participant E as Product index
  participant L as Live partners
  A->>S: q milk, at 12.9352, 77.6245
  S->>S: point to H3 cell, resolution 9
  S->>Z: which stores deliver to this cell?
  Z-->>S: stores 41 and 57
  S->>E: match milk, filter in stock at 41 or 57
  E-->>S: 23 products, scored
  S->>L: free partners near 41 and 57?
  L-->>S: 6 and 2, ETA estimates
  S-->>A: results with a delivery-time promise

The cell size comes from the radius:

  • Geohash: the longest precision whose cell is at least as wide as the radius, then the 9-cell search.
  • H3: grid_disk(origin, k) must contain every point within r. For edge length e, a safe k is ceil((r + 2e) / 1.5e) − 1, since the query point and a far cell's points can each be up to e from their centres. Take e from the cells where you are, not H3's global average: cell sizes vary by place, and in Mumbai, where res-9 cells are ~9% smaller than average, the average gives k = 7, which misses points 2 km away. The code below uses the query cell's shortest edge, to be safe. Or compute an exact covering of the circle.
  • S2: let S2RegionCoverer choose within a max_cells budget.

6.2 k-Nearest: Grow the Search, Then Prove You're Done#

"The 5 nearest free partners" has no radius. You grow one: scan the origin cell, then ring 1, ring 2, and so on. The trap is when to stop.

Your turn · checkpoint 3not answered

What breaks if you stop at the first ring with 5 results?

A kNN search scans H3 rings outward from the store and stops as soon as it has 5 partners. Ring 1 returns 5 partners. Can a closer partner be sitting in ring 2? Draw it before reading on.

Think it through, or jot it down. Then compare.

One strong answer

  • Yes. The store can sit near the edge of its own cell, so part of ring 2 is closer to it than the far side of ring 1. A partner just across that edge can beat the 5th partner ring 1 found.
  • The rule: after each ring, compute a lower bound on the distance to anything not yet scanned. Stop only when you have k results and the k-th is no farther than that bound.
  • With uneven density it gets worse: in a sparse suburb the first ring with 5 partners may be ring 6, and the true nearest may be one ring further out.
pythonshortest_edge_km · 23 lines
import h3, heapq

def shortest_edge_km(cell):
    """H3 cells vary in size by place: use this cell's own shortest edge, not the global average."""
    return min(h3.edge_length(e, unit="km") for e in h3.origin_to_directed_edges(cell))

def ring_bound(ring, edge_km):
    """Closest that any point beyond `ring` can be: a conservative lower bound."""
    return (ring + 1) * 1.5 * edge_km - 2 * edge_km

def k_nearest(cells, lat, lng, k, res=8, max_rings=40):
    """cells: {h3 cell: {partner_id: (lat, lng)}}. Returns the k nearest (km, id)."""
    origin = h3.latlng_to_cell(lat, lng, res)
    edge = shortest_edge_km(origin)
    found = []
    for ring in range(max_rings + 1):
        for cell in h3.grid_ring(origin, ring):              # only the new ring
            for pid, pos in cells.get(cell, {}).items():
                found.append((h3.great_circle_distance((lat, lng), pos, unit="km"), pid))
        best = heapq.nsmallest(k, found)
        if len(best) == k and best[-1][0] <= ring_bound(ring, edge):
            return best                                       # nothing unscanned can beat these
    return heapq.nsmallest(k, found)                          # fewer than k within max_rings

Straight-line distance only picks candidates; dispatch re-ranks the top few by road ETA. And cap the search (max_rings, Redis's COUNT) so an empty area doesn't scan half the state.

Part V · Stage 5 of 8 · 7 min

Part V: Things That Move#

Chapter 7: Partners Are Not Stores#

7.1 Two Indexes, Two Workloads#

Stores and partners both have a location, and the launch-night mistake was treating them alike.

POI index (stores, restaurants) Live index (partners, drivers)
Writes A few a second Every partner, every 4 s
Reads Text plus filters plus location "Who is free near here?"
Freshness Minutes is fine Seconds, or the answer is wrong
Home Elasticsearch, PostGIS Memory: a cell index or Redis GEO

Elasticsearch is the wrong home for 4-second pings. An update isn't in place: the document "must still be reindexed" (Elastic docs), so each ping writes a new copy and deletes the old, and refreshes (every second on searched indices) make segments that merges must clean up. At 10,000 pings a second, that's the 20:09 incident.

7.2 The Write Path#

Your turn · checkpoint 4not answered

Before the numbers: how many writes, and how many matter?

300,000 partners are online nationally at the evening peak (illustrative), each sending a position every 4 seconds and riding at 15 km/h (the figure Eternal's then CEO gave for Blinkit riders, 2026). The live index uses H3 resolution 8: an average hexagon of 0.737 km² with 531 m edges. How many position writes a second? How often does a riding partner change cell, and how many cell-index updates a second is that?

Think it through, or jot it down. Then compare.

One strong answer

  • Writes: 300,000 ÷ 4 = 75,000 position updates a second.
  • Movement: 15 km/h ≈ 4.2 m/s ≈ 17 m per ping. A straight path through a cell averages π × area ÷ perimeter ≈ 0.73 km (most paths clip a hexagon rather than cross it centre to centre), about 175 s of riding, so roughly 1 ping in 45 changes cell.
  • Cell-index updates: 75,000 ÷ 45 ≈ 1,700 a second, even with everyone moving. The other 98% overwrite a position in place.
  • So design for 75,000 cheap overwrites a second and ~1,700 set moves, not 75,000 index updates.

The trick for moving objects: overwrite the position on every ping, touch the cell index only when a partner crosses a cell edge.

WorksheetThe live location index for "Now" (partner counts, density and payload are illustrative; the 4 s interval is Uber's published one, the 15 km/h is Eternal's)
Value
Partners online at the evening peak, nationally / in Bengaluru 300,000 / 40,000
Position updates (one every 4 s) 75,000/s nationally, 10,000/s in Bengaluru
Ingest at ~200 B per ping with headers ~15 MB/s, ~120 Mbps
Cell changes at 15 km/h, H3 res 8 (~0.73 km of path per cell on average) 1 ping in ~45 → ~1,700/s
Bengaluru density: 40,000 over ~700 km² ~57 partners per km²
Query: free partners within 2 km of a store (circle 12.6 km²) ~720 really inside
Geohash 5 (4.8 × 4.9 km in Bengaluru), cell + 8 neighbours 9 cells, ~210 km², ~12,000 candidates read (17×)
Geohash 6 (1.19 × 0.61 km) 26 to 32 cells, ~22 km², ~1,250 candidates (1.7×)
H3 res 7 (edge 1.41 km), grid_disk k = 2 19 cells, ~98 km², ~5,600 candidates (8×)
H3 res 8 (edge 531 m), grid_disk k = 3 37 cells, ~27 km², ~1,560 candidates (2.2×)
H3 res 9 (edge 201 m), grid_disk k = 7 169 cells, ~18 km², ~1,000 candidates (1.4×)

Cell counts checked by script for random points around Koramangala (h3 4.4.2 for the hexagons), and the 1 in 45 by simulating riders. The H3 k values use the average edge, which is enough here, where cells are ~3% larger than average. Geohash 5 often needs only 1 to 4 cells, but the 9-cell search is what guarantees coverage.

Conclusion: design for 75,000 writes a second, and index only cell changes to cut churn ~45×. For a 2 km query, H3 res 8 or geohash 6 reads about twice the real answer in a few dozen lookups; the coarse 9-cell search reads 17×, 12,000 distance computations per query.

DiagramLive locations: overwrite in memory, move between cells rarely, keep history elsewhere
%% title: Live locations: overwrite in memory, move between cells rarely, keep history elsewhere
flowchart TD
  P@{ icon: "sd:phone", label: "Partner app<br/>ping every 4 s" }
  GW@{ icon: "sd:gateway", label: "Ingest gateway<br/>auth, rate limit" }
  K@{ icon: "tech:kafka", label: "Kafka · location trail<br/>keyed by partner" }
  LIVE["Live index shard<br/>partner → position, cell, seen at<br/>cell → partners"]:::core
  DISP["Dispatch and ETA<br/>who is free near store 41?"]:::service
  LAKE@{ icon: "sd:object-storage", label: "History<br/>trips, analytics, disputes" }
  SRCH@{ icon: "sd:search", label: "Search API<br/>delivery promise" }
  P ==> GW ==> LIVE
  GW --> K --> LAKE
  DISP --> LIVE
  SRCH --> LIVE
  class P client
  class GW edge
  class K queue
  class LAKE store
  class SRCH service
pythonLiveLocations · 36 lines
import math, time
import h3

class LiveLocations:
    """Partner positions in memory, bucketed by H3 cell. Most pings touch only the position."""

    def __init__(self, res=8, ttl_s=30):
        self.res, self.ttl = res, ttl_s
        self.pos = {}      # partner -> (lat, lng, cell, seen_at)
        self.cells = {}    # cell -> set of partners

    def ping(self, partner, lat, lng, now=None):
        now = now or time.time()
        cell = h3.latlng_to_cell(lat, lng, self.res)
        old = self.pos.get(partner)
        if old is None or old[2] != cell:                 # about 1 ping in 45 at res 8
            if old:
                self.cells[old[2]].discard(partner)
            self.cells.setdefault(cell, set()).add(partner)
        self.pos[partner] = (lat, lng, cell, now)          # every ping: overwrite in place

    def nearby(self, lat, lng, radius_km, now=None):
        now = now or time.time()
        origin = h3.latlng_to_cell(lat, lng, self.res)
        e = min(h3.edge_length(d, unit="km") for d in h3.origin_to_directed_edges(origin))
        k = math.ceil((radius_km + 2 * e) / (1.5 * e)) - 1   # this cell's size, not the average
        hits = []
        for cell in h3.grid_disk(origin, k):
            for p in self.cells.get(cell, ()):
                plat, plng, _, seen = self.pos[p]
                if now - seen > self.ttl:                  # app killed, phone off: not "nearby"
                    continue
                d = h3.great_circle_distance((lat, lng), (plat, plng), unit="km")
                if d <= radius_km:                         # refine: exact distance
                    hits.append((d, p))
        return sorted(hits)

The TTL matters as much as the index: a phone that dies at a traffic light never says goodbye, and without an expiry stays "2 minutes away" forever. It's Week 6, Day 4's presence again: positions are hints that expire.

Redis GEO offers this as a service: GEOADD keeps members in a sorted set scored by a "52-bit integer" of interleaved latitude and longitude bits, and GEOSEARCH (Redis 6.2+) queries it. From the docs: longitude comes first; the Earth is modelled as a sphere, with error "up to 0.5%"; and COUNT n ANY returns as soon as it has n matches, which may be unsorted and not the nearest, but far cheaper in a crowd. Sorted-set members have no TTL of their own, so you track last-seen and remove stale partners yourself (ZREM).

text2 lines
GEOADD    partners:blr 77.6245 12.9352 partner:8812
GEOSEARCH partners:blr FROMLONLAT 77.6245 12.9352 BYRADIUS 2 km ASC COUNT 20 WITHDIST

7.3 Sharding Geo Data and Hot Cells#

One machine holds a city's partners easily; the national write rate, read traffic and failure isolation force sharding. The shard key is a cell:

  • By city or region. Simple and local, but cities aren't equal, and one Redis key per city puts a whole city's writes on one node.
  • By cell range along a space-filling curve. S2's Hilbert order keeps neighbours together, so a shard is a run of cell IDs, rebalanced by moving split points to even out load, not area. Uber sharded on S2 cell IDs in 2015.
  • By coarse hexagon. Uber Eats search grouped "thousands of narrow latitude stripes" into shards offline with Spark; they go uneven "in densely populated urban areas", so it also uses hex sharding on H3, typically "size 2 or 3" (Narayanan and Ramasamy, InfoQ, 2025, from their QCon San Francisco 2024 talk).

Every scheme has boundaries: a search near a shard edge needs both shards. Query both (the router knows which shards the covering's cells live on) or, for read-mostly POIs, index edge documents in both. Uber Eats does the latter: "documents falling on shard boundaries are indexed in both neighboring shards".

DiagramShards are runs of cells, sized by load: the city centre gets small ones
%% title: Shards are runs of cells, sized by load: the city centre gets small ones
flowchart TD
  RT["Geo router<br/>covering cells → shards"]:::core
  S1@{ icon: "sd:servers", label: "Shard 1<br/>north and west Bengaluru<br/>many cells, light load" }
  S2@{ icon: "sd:servers", label: "Shard 2<br/>east Bengaluru, Whitefield" }
  S3["Shard 3<br/>central business district<br/>few cells, heavy load"]:::hot
  S3R@{ icon: "sd:servers", label: "Shard 3 read replicas<br/>for match-night reads" }
  C@{ icon: "sd:cache", label: "Nearby cache<br/>key: cell, 5 s TTL" }
  Q@{ icon: "sd:map-pin", label: "Query near a shard edge<br/>covering spans shards 2 and 3" }
  Q ==> RT
  RT --> C
  RT ==> S2
  RT ==> S3
  RT -.-> S1
  S3 --> S3R
  class S1,S2,S3R store
  class C cache
  class Q client
Your turn · checkpoint 5not answered

The match ends: 20:31 on launch night

The live index has one shard per city. When the match ends, 30,000 people open the app within 10 minutes in the two resolution-8 cells around the stadium. Bengaluru's shard hits 100% CPU and "nearby" requests time out across the city, including Whitefield, 15 km away. Why did Whitefield suffer, and what do you change, now and for next time?

Think it through, or jot it down. Then compare.

One strong answer

  • Why: one shard per city lets one hot cell slow every Bengaluru request, and each stadium request scans cells packed with partners, so cost per request rose with the count.
  • Now: cache the nearby answer per cell for a few seconds (30,000 requests about 2 cells become a handful of computations), cap candidates with COUNT ... ANY, fall back to a cached delivery estimate.
  • Next time: shard by load-sized cell ranges, so the stadium's cells are their own small shard with read replicas.

Hot cells are hot keys (Week 1, Day 4 · Hot Keys and Skew) with a map attached: a stadium, a station at 7 pm, a mall on sale day. The fixes:

  1. Split by load, not area: smaller cell ranges where it's busy, the quadtree's insight applied to shards.
  2. Cache by cell: everyone in the stadium asks about the same few cells, so cache each answer for a few seconds.
  3. Replicate reads of a hot shard; writes stay on the primary.
  4. Cap the work: COUNT 20 ANY, max_rings. No query needs all 3,000 partners in a cell.
  5. Degrade: show the cell's recent average delivery time instead of a live computation.

Part VI · Stage 6 of 8 · 3 min

Part VI: Operating Search at Scale#

The geo layer lives inside this week's search cluster, which still has to be sized and run. The compact version:

Chapter 8: Topology, Shard Sizing and Reindexing#

8.1 Cluster Topology#

DiagramProduction cluster: roles separated, copies spread across zones
%% title: Production cluster: roles separated, copies spread across zones
flowchart TD
  APP@{ icon: "sd:api", label: "Search API" }
  subgraph CO["Coordinating nodes · route and merge"]
    direction LR
    C1@{ icon: "sd:router", label: "Coord 1" }
    C2@{ icon: "sd:router", label: "Coord 2" }
  end
  subgraph MA["Dedicated master-eligible nodes · cluster state only"]
    direction LR
    M1["Master 1<br/>zone A"]:::core
    M2["Master 2<br/>zone B"]:::service
    M3["Master 3<br/>zone C"]:::service
  end
  subgraph DA["Data nodes · shards, queries, indexing"]
    direction LR
    D1@{ icon: "sd:database", label: "Data · zone A<br/>primaries and replicas" }
    D2@{ icon: "sd:database", label: "Data · zone B" }
    D3@{ icon: "sd:database", label: "Data · zone C" }
  end
  APP ==> CO
  CO ==> DA
  MA -.->|"shard allocation"| DA
  class APP edge
  class C1,C2 edge
  class D1,D2,D3 store
  • Three master-eligible nodes: "so that if one of them fails then the remaining two still form a majority" (Elastic). Four tolerate no more failures than three.
  • Coordinating nodes fan queries out to shards and merge results, so data nodes do only shard work.
  • Data nodes hold "at least two copies of each shard", primary and replica in different zones.
  • Heap: at most 50% of RAM, leaving the rest to the file cache where the index lives, and below the compressed-pointers threshold: "26GB is safe on most systems and can be as large as 30GB on some". The old "31 GB" rule of thumb goes past that range; don't use it as a target.

8.2 Shard Sizing and Capacity#

Elastic's guidance: "Aim for shard sizes between 10GB and 50GB", under "200 million" documents each. Too few and too many both hurt: "each shard runs the search on a single CPU thread", so more shards mean more parallelism, but searching "a large number of shards can deplete a node's search thread pool". By default a node takes at most 1,000 non-frozen shards.

The product index from earlier this week, worked through:

Step Value
50M products × 10 KB (~477 GB in Day 1's binary units), plus ~30% index overhead ~620 GB
× 1.5 growth (primary count is fixed per index) ~930 GB
÷ ~30 GB per shard, rounded 32 primaries, ~29 GB each; 64 copies with a replica
Peak 50,000 queries/s ÷ ~150 per core (assumed) ~330 cores, ~21 data nodes of 16 cores

Data nodes = the largest of what storage, memory and CPU each need: storage at normal traffic, CPU at peak. Location adds a lever: geo-partitioned indices. If queries are local, an index per region means a Bengaluru search touches Bengaluru's shards, not all 32, and a hot region can get more replicas. Tinder's Elasticsearch setup does this: "partitioning users into geo-indexes" so it "examines only nearby profiles instead of the entire global user base", which "lowered CPU usage by four times" (Elastic customer story).

8.3 Reindexing With Aliases#

Geo changes force reindexes: a new h3_r9 field for cell filters, geo_point to geo_shape for zones, a new shard count. Day 2 built the full procedure; in short:

  1. Clients only use an alias (products-now), never an index name.
  2. Create products-now-v2, backfill it while the pipeline writes to both.
  3. Verify counts and sample queries against v1.
  4. Swap the alias in one request: "multiple actions in a single atomic operation" (Elastic), so it never points at both or neither.
  5. Keep v1 for rollback: the same swap the other way.
json7 lines
POST _aliases
{
  "actions": [
    { "remove": { "index": "products-now-v1", "alias": "products-now" } },
    { "add":    { "index": "products-now-v2", "alias": "products-now" } }
  ]
}

Part VII · Stage 7 of 8 · 2 min

Part VII: Real-World Application#

Chapter 9: How Real Companies Do It#

Who What's published
Uber, 2015 (QCon London, via InfoQ and HighScalability) Phones post a location every four seconds; level-12 S2 cells (3.31 to 6.38 km², per HighScalability) as the shard key; a target of a million writes a second
Uber, 2018 (Engineering blog, h3geo.org) H3: hexagons plus 12 pentagons, 16 resolutions, each 1/7 the area of the last; surge pricing measured in hexagons
Uber Eats search, 2025 (InfoQ, from QCon SF 2024) Latitude-stripe shards went uneven in dense cities; hex sharding on H3; boundary documents indexed in both shards
Tinder (Elastic customer story) Users partitioned into geo-indexes on Elasticsearch; CPU down four times
India Post (DIGIPIN case file) A national code from a 4 × 4 grid split ten times, computed rather than looked up

Chapter 10: Common Mistakes to Avoid#

10.1 Mistake 1: A B-tree on Latitude and Longitude#

Indexing "near"

WRONG: (lat, lng) composite or two indexes

  • Reads a strip across the country, filters the rest
  • Fine in a demo with 400 stores, slow at 20 million

CORRECT: cells or a spatial tree

  • Geohash, S2 or H3 keys, or a GiST/BKD spatial index
  • Cover the radius, then refine by exact distance

10.2 Mistake 2: Pings in the Search Index#

Where moving objects live

WRONG: update a search document every 4 s

  • Every ping reindexes a document and churns segments
  • Product search slows down for positions nobody searches by text

CORRECT: memory for now, a log for history

  • Overwrite in place; move between cells only on a cell change
  • TTL on every position; the trail goes to Kafka and the lake

10.3 Mistake 3: Trusting the Prefix#

Edges

WRONG: same cell or same prefix = nearby

  • Misses a partner 43 m away across an edge
  • kNN stops at the first ring that has enough results

CORRECT: neighbours, bounds, refine

  • Search the cell plus neighbours, or a covering
  • Stop kNN only when the k-th result beats the bound

10.4 Mistake 4: One Shard per City, One Size for Every Cell#

Sharding geo data

WRONG: shard by city, fixed cells

  • One stadium slows the whole city
  • Shards as uneven as the population

CORRECT: cell ranges sized by load

  • Small ranges and read replicas where it's busy
  • Per-cell caching and capped queries for hot spots

Part VIII · Stage 8 of 8 · 5 min

Part VIII: Interview Preparation#

Chapter 11: Interview Tips and Phrases#

11.1 When to Bring It Up#

  • "Find nearby" anything: proximity is the deep dive.
  • Ride-hailing, food or quick commerce: expect the moving-object write path and hot cells.
  • E-commerce search with local delivery: the cell → stores lookup plus a product filter.
❯ Say this

11.2 Key Phrases#

Key phrases
FRAMING

"A B-tree sorts by one number and 'near' is a circle. I'll map space to cells whose IDs sort well, cover the query with a few cells, then refine by exact distance."

CHOOSING A GRID

"Geohash if it has to live in Redis or a plain key-value store, S2 for coverings and range-sharding, H3 for moving things and anything aggregated over neighbours, and PostGIS when the data is polygons."

MOVING OBJECTS

"Positions are soft state: overwrite in memory with a TTL, touch the cell index only when a partner changes cell, and send the trail to a log. In my estimate that's 75,000 writes a second but only about 1,700 index moves."

HOT CELLS

"Shard by load-sized cell ranges, cache per cell for seconds, cap every query."

11.3 Questions to Ask the Interviewer#

  • Are the things we search for static, moving or both? How often does a moving one report?
  • Is it "within r" or "the nearest k"? Straight-line or road distance?
  • How uneven is the density: one city or the whole country?

11.4 Common Follow-Up Questions#

Question Strong answer
"Two points 40 m apart have different geohashes. Is that a bug?" No, it's the Z-order curve's edge. Search the cell plus 8 neighbours, or use a covering, then refine.
"Why hexagons?" One neighbour distance instead of two or three, so rings approximate circles and smoothing treats neighbours alike. The cost: children don't nest exactly.
"How do you find the 5 nearest when you don't know the radius?" Grow rings and stop when the 5th result is closer than a lower bound on anything unscanned.
"How would you shard this?" Load-sized cell ranges; edge queries go to both shards, or edge POIs live in both.

Chapter 12: Practice Problems#

Practice · problem 1

Problem 1: A Proximity Service#

Design "places within r km" for 200 million businesses at 15,000 searches a second. The proximity service problem walks through it.

Hint: the whole geo index is about 10 GB. Replicate it, don't shard it, and cache by cell.

Practice · problem 2

Problem 2: Nearest Ambulance#

A state's emergency service has 3,000 ambulances that report every 5 seconds and 100 calls a minute at peak (illustrative). A call must get the 3 nearest available ambulances by road ETA within 2 seconds, even in rural districts with one ambulance per 200 km².

Hint: growing rings with a proper stopping bound, coarser cells for sparse districts, then road ETA on the top 10.

Practice · problem 3

Problem 3: Delivery Zones That Change by the Hour#

Ops shrink a dark store's delivery polygon during heavy rain and grow it back after. 2,000 stores, zones edited a few times a day, and every app open must know which stores serve the customer's location.

Hint: a cell → stores table at H3 resolution 9, rebuilt per store on each edit and versioned so a half-applied edit is never visible. The Uber side quest uses the same cell machinery for drivers.

Summary#

DAY 5 KEY TAKEAWAYS
THE NEARBY PROBLEM
  • A B-tree sorts by one key; "near" needs cells whose IDs keep neighbours close
  • Cover with cells, refine by exact distance; cell size is the knob
THE GRIDS
  • Geohash: 5 bits a character; neighbours can share no prefix
  • Quadtree: adapts to density; static for POIs
  • S2: numbered quadtree on a cube, Hilbert order, coverings
  • H3: hexagons, one neighbour distance, inexact nesting
  • R-tree / GiST, BKD: spatial trees in PostGIS and Elasticsearch
MOVING OBJECTS AND SCALE
  • 300,000 partners every 4 s is 75,000 writes a second; only ~1 in 45 changes an H3 res-8 cell
  • Positions are soft state in memory with a TTL, never search documents
  • Shard by load-sized cell ranges; cache, replicate and cap hot cells
OPERATING THE CLUSTER
  • Three masters, copies across zones; heap ≤ 50% of RAM and ~26 GB
  • Shards of 10–50 GB; geo-partition when queries are local
  • Reindex behind an alias, swap atomically

Further Reading#

Engineering Blogs and Talks#

Official Documentation#

Papers#

this lesson in shorts · 4

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 05 · Location Search and Search at Scale
Finish Day 5Stages clear as you read them (0/8 so far). Skipping ahead doesn't count: marking complete unlocks after ~6 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

UberBlog post · ~8 min

Uber's Hexagonal Hierarchical Spatial Index

Isaac Brodsky · 2018
What
Uber's announcement of H3, the open-source grid of hexagonal cells it uses to analyse its marketplace across cities.
Key idea
Hexagons keep every neighbour at one distance and limit quantization error as users move; 16 resolutions each have one seventh the area of the one above.
Why here
This lesson's case for hexagons: rings that approximate circles and fair smoothing, as in surge pricing measured per hexagon.
taught here: H3 hexagons

uber.com answers a plain command-line client with 406; title and content verified with a second fetcher on 2026-10-29.

UberBlog post · ~25 min

Optimizing Search Systems: Balancing Speed, Relevance, and Scalability

Janani Narayanan, Karthik Ramasamy · 2025
What
Uber Eats engineers on scaling search, from their QCon San Francisco 2024 talk, including how geospatial queries are sharded.
Key idea
Latitude-stripe shards go uneven in dense cities, so hex sharding on H3 (typically size 2 or 3) balances them; boundary documents are indexed in both neighbouring shards.
Why here
This lesson's shard boundaries and hot cells: split by load, not area, and duplicate edge documents so a search near an edge misses nothing.
taught here: Sharding geo data
ElasticDocs · ~15 min

Size your Elasticsearch shards

What
Elastic's official guidance on how many shards to create and how large to make them.
Key idea
Aim for 10 GB to 50 GB and under 200 million documents a shard; each shard searches on one CPU thread, and too many shards can deplete a node's search thread pool.
Why here
The shard-sizing step of this lesson's capacity plan, which turns the week's product index into 32 primaries.
taught here: Shard sizing

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…