"Restaurants near me" looks like the same question as "drivers near me" and it is almost
the opposite one. Drivers move constantly and are read occasionally. Restaurants move
never and are read constantly.
Proximity service / at a glance
That single inversion changes every decision. Where the ride hailing design keeps
everything in memory and refuses to write to disk, this one precomputes aggressively,
caches for hours, and treats a business moving address as a rare event that can afford to
be slow. Getting this distinction out loud in the first two minutes is most of the
interview.
A business changes address a handful of times ever. New ones open daily.
Everything. This is a read heavy index over near static data, so it can be precomputed, replicated everywhere and cached for hours. If you have just designed a ride hailing service, say out loud that this is the mirror image of it.
How many places, and how many searches?
A couple of hundred million places, tens of thousands of searches a second.
The index fits on one machine, so every server can hold a full copy and there is no partitioning problem to solve. That is a rare luxury and it should shape the design.
What does "near" mean: a radius, or a count?
The twenty best within about five kilometres, whichever binds first.
A variable radius search, which is harder than a fixed one. In a dense market five kilometres returns thousands and you need the best twenty, while in a rural one it returns three and you should widen rather than return three.
What makes a result good, besides being close?
Rating, whether it is open now, and relevance to what was typed.
Two stages. Geography is a filter, not a ranking, and the actual ordering is a scoring problem over a small candidate set.
Does the result have to be exact at the boundary?
No. Nobody notices a place at 5.1km being included.
Permission to use approximate cells instead of true distance during the filter, which is what makes the filter a set lookup rather than a geometry computation.
Is it personalised?
Lightly. Mostly it is the same for everyone in the same place.
Results cache well if you quantise the query location. Two people fifty metres apart should get the same cache entry, and that one decision multiplies your hit ratio.
Cells to scan at this radius(5km / 1.2km)^2 = 19 cells
Average places per cell4.8
Searches per second30,000
12.0 GB
for the whole world, which means every server can hold all of it
That headline is the design. At 12.0GB the geographic index fits comfortably in memory on any server you would rent, so you replicate it everywhere instead of sharding it, and a search never crosses the network to find candidates. The 80GB of full records stays in a normal database and is only read for the twenty results you actually return.
The number that changes the design
Notice what a larger radius does to the cell count: it grows with the square. At 5km
you scan about twenty cells, at 25km about five hundred. A naive implementation that
lets a client pass any radius has a request that is a thousand times more expensive
than another, on the same endpoint, with the same timeout. Either cap the radius or use
coarser cells for wider searches, and say which before someone finds it in production.
Why: Coordinates are rounded to about four decimal places before they reach the cache key, so two people on the same street share a cache entry. Passing full precision through is the most common reason these endpoints have a 3% cache hit ratio and nobody can work out why.
GET/v1/nearby?cell=tdr1vx&category=restaurant
returns 200, cacheable for 10 minutes
Why: The cell addressed variant is what a CDN can actually cache, because there are a bounded number of cells in the world and an unbounded number of coordinates. Worth offering to clients that can tolerate cell granularity, which most map interfaces can.
Why: 202 with an honest estimate. A write here means rebuilding part of an index and invalidating caches, so promising immediate searchability would be a lie. Telling the caller how long it takes stops them from polling.
DELETE/v1/places/{id}
returns 204
Why: Closures propagate faster than additions, and deliberately so. Showing a place that opened yesterday a few minutes late costs nothing. Sending someone to a restaurant that shut down is the failure people remember and write reviews about.
This structure is rebuilt as a whole and shipped to every search node, exactly like the autocomplete index. It is small enough that replication beats partitioning, which removes fan out, hot shards and rebalancing from the design in one decision.
placesrelational or document store, sharded by place id
place_id
bigint
PK
name, address, phone
text
The heavy fields. Only read for the twenty results you return, never during the filter.
lat, lng
double
The authoritative position. The cell in the index is derived from this, not the other way round.
hours
json
IDX
Open now is computed at read time in the user timezone. Precomputing it means rebuilding the index every hour for no reason.
status
enum
open, temporarily_closed, permanently_closed. Checked at serve time, because a stale index must never resurrect a closed business.
Two stores again, split by access pattern rather than by entity. The index answers which places, the store answers what they are, and only twenty rows are ever fetched from the second one.
often ends herelat, lng, radius1cell + neighbours2category AND3a few hundred candidates4store the top 20hydrate 20 rows56swap in a new version7closed, immediatelyClienta map, usuallyCDNcell keyed resultsSearch nodesfull index eachCell indexin memory, replicatedCategory bitmapsAND, then truncateRankingdistance, quality, openPlace storenames, hours, statusResult cachequantised locationPlace updatesowners, importsIndex builderevery few minutesClosure feedfast path
often ends herelat, lng, radius1cell + neighbours2category AND3a few hundred candidates4store the top 20hydrate 20 rows56swap in a new version7closed, immediatelyClienta map, usuallyCDNcell keyed resultsSearch nodesfull index eachCell indexin memory, replicatedCategory bitmapsAND, then truncateRankingdistance, quality, openPlace storenames, hours, statusResult cachequantised locationPlace updatesowners, importsIndex builderevery few minutesClosure feedfast path
Figure 1. Filter then rank, cheap then expensive. The geo index narrows two hundred million places to a few hundred using set operations, and only then does anything look at a real record.
Walking Figure 1:
A search arrives with a point and a radius. The coordinates are quantised before
anything else happens, which is what makes the next step cacheable.
The node computes the covering cells for that circle and reads them from its own
memory. No network call, because every node holds the whole index.
Category filters are bitmap operations over the candidate set rather than a lookup per
place.
A few hundred candidates go to ranking, which combines real distance, precomputed
quality, and whether the place is open right now.
Only the surviving twenty are hydrated from the place store. This is the only database
read in the request.
Separately, place updates trickle in from owners and bulk imports.
Every few minutes a builder produces a new index version and ships it to every node,
which swaps it in atomically.
The dashed closure feed exists because the batch cycle is too slow for one specific case.
A permanently closed business must disappear now, not in three minutes, so closures are
applied to the serve path directly rather than waiting for a rebuild.
Your answer
Step 2 reads the cells covering a 5km circle. A user in Manhattan and a user in rural Rajasthan run the same query. Describe what goes wrong for each of them with a single fixed cell size.
Cells are squares and a search is a circle, so every cell scheme is an approximation and
the interesting question is which way you round.
The rule is to over cover and then filter. Take every cell that intersects the circle,
union their contents, then compute true distance on the candidates and drop the ones
outside the radius. Under covering means missing a place that was genuinely nearby, which
users notice. Over covering means doing slightly more work, which they do not.
One cell size everywhere. Simple, cheap, and wrong at both ends: in a dense city twenty cells return thousands of candidates and ranking becomes the bottleneck, while in a rural area the same twenty cells return almost nothing and the user gets a nearly empty page. Start here, because it is easy to reason about, and know both failure modes.
The answer that survives contact with production is usually adaptive levels for the index
plus expanding rings for the query, and it is worth saying you would combine them. The
density map keeps candidate counts even, and the ring expansion handles the cases the
density map got wrong.
The most common mistake in this question is treating distance as the answer. It is a
filter, and the ranking that follows is where the product lives.
ClientSearch nodeCell indexRankerPlace storelat, lng, 5km, restaurantscells covering the circle~2,400 place idscategory bitmap AND, then distance~300 candidateshydrate 20 by id
1/6 The first thing the node does is round the coordinates. Full precision would make every request a unique cache key and destroy the hit ratio for no benefit at all.
Figure 2. Two hundred million places become a few hundred candidates using nothing but set operations, and only twenty of them ever cause a database read.
The shape of that sequence, cheap filter into expensive rank into tiny hydration, is the
same one behind the search engine's leaf shards and the ride hailing service's candidate
list. When you recognise it, most retrieval questions become the same question.
The follow up you will get
"How do you handle 'open now' if hours are in the index?" Do not put them in the index.
Opening hours change the answer every hour, so baking them in means rebuilding
constantly. Keep hours on the place record, evaluate them at serve time in the user's
timezone, and over fetch candidates so that filtering out the closed ones still leaves
twenty. Over fetching by a factor of two is far cheaper than an hourly index rebuild.
At a few gigabytes, the whole world fits on every search node, and that is worth taking
advantage of rather than reflexively partitioning.
Sharding geographically sounds natural and creates problems you then have to solve.
Traffic is wildly uneven, so the shard holding Tokyo is a hundred times busier than the
one holding the Sahara. Searches near a boundary have to fan out to several shards and
merge. Rebalancing means moving cells while queries are running. All of that disappears
if every node holds everything: a search is local, any node can serve any request, and
scaling out is adding an identical machine.
The cost is that every node needs enough memory for the whole index and every rebuild
ships to every node. Both are fine at this size. Say where the threshold is, though: once the index stops
fitting comfortably in memory on one machine, you shard by cell range, with the hot
regions split more finely than the empty ones.
Where the caching actually happens
The most valuable line of code in this system is the one that rounds the coordinates
before building the cache key. Full precision means every request is unique and your
cache does nothing. Rounding to about a hundred metres means everyone in the same block
shares an entry, and a cache hit ratio that was three percent becomes seventy. It is a
one line change with a bigger effect than any amount of index cleverness, which is
worth saying out loud because it demonstrates you know where the wins are.
30k/sradius 50kmfestival in one cityindex rebuildrepaired
misshydrate 20ClientsResult cacheSearch nodesfull indexPlace store
misshydrate 20ClientsResult cacheSearch nodesfull indexPlace store
Healthy. Design load with quantised cache keys. Most searches are answered from cache, misses are a memory scan of twenty cells, and the store sees twenty key reads per miss. Everything is comfortable.
Read every step as text
30k/s · Healthy. Design load with quantised cache keys. Most searches are answered from cache, misses are a memory scan of twenty cells, and the store sees twenty key reads per miss. Everything is comfortable.
radius 50km · Degraded. Someone sets a wide radius. Cell count grows with the square of the radius, so this single request scans five hundred cells and produces tens of thousands of candidates. One user can now cost a thousand times what another does on the same endpoint.
festival in one city · Down. Everybody searches from the same few blocks. The quantised cache keys collapse to a handful of entries, which is good, but those entries are on one cache node and it saturates. A hot key problem produced by the very thing that gave you a good hit ratio.
index rebuild · Degraded. A new index version ships to every node. Memory doubles during the swap and the first queries after it run against cold CPU caches. Stagger it, and never let more than a fraction of the fleet swap at once.
repaired · Healthy. Cap the radius and use coarser cells above a threshold, so a wide search is cheap and slightly less precise rather than expensive and exact. For the hot city, keep a small local cache in each search node in front of the shared one, which is the same one second trick from the URL shortener.
The festival case is a nice illustration that optimisations create their own failures.
Quantising locations gave you a high hit ratio by concentrating requests onto fewer keys,
and concentrating requests onto fewer keys is the definition of a hot key.
A user opens the app and wants restaurants near them. Walk me through it.
Opening. Watching for whether you reach for a database with a spatial extension.
You
We round the coordinates first, to about a hundred metres, so that everyone on the same block shares a cache key. On a miss, the search node computes the cells covering the radius and reads them from its own memory, because the whole geographic index is only a few gigabytes and every node holds a full copy. That gives a few thousand candidates, we AND a category bitmap, compute real distance on what is left, rank a few hundred, and hydrate only the top twenty from the place store. One database read, for twenty keys.
Leads with the quantisation, which is the decision with the biggest effect, and ends with the number of database reads. Both are memorable.
Interviewer
Why replicate the index instead of sharding it geographically?
Checking whether that was a decision or an accident.
You
Because it fits. A few gigabytes for two hundred million places means every node can hold everything, and that removes fan out at boundaries, hot shards over dense cities and rebalancing, all at once. Geographic sharding is wildly unbalanced by construction, since Tokyo and the Sahara are the same area and not the same traffic. I would shard only when the index stops fitting in memory, and then by cell range with hot regions split finer.
Names the three problems replication avoids and states the condition under which the answer flips.
Interviewer
How is this different from finding nearby drivers?
They want to know whether you understand why the same data structure gives a different design.
You
The read to write ratio inverts. Drivers are fifty thousand writes a second against a small read rate, so that index lives in memory precisely because it is disposable and rebuilt every four seconds. Places almost never change, so this index is precomputed, replicated and cached for minutes. Same cells, opposite reasoning: there I optimise for write throughput and accept losing state, here I optimise for read cost and accept minutes of staleness.
Comparing two systems by their read to write ratio is exactly the kind of transfer interviewers are testing for.
Interviewer
A restaurant permanently closes. When does it stop appearing?
A correctness question hiding in a freshness question.
You
Not on the normal three minute cycle. Sending someone to a business that shut down is the failure people remember, so closures get a fast path: the status lives on the place record, it is checked at hydration time, and a closure feed pushes it immediately. The index can keep the stale id for a few minutes, because a candidate that gets filtered at serve time costs nothing. Additions can wait, removals cannot.
Separates the two directions of freshness, which most candidates treat as one requirement.
Interviewer
What cell size would you pick?
Open ended, and the honest answer is "it depends" with a method attached.
You
I would not pick one globally. I would target a number of places per cell rather than a distance, something like a few hundred, and let the level vary by region from a density map computed at build time. That keeps candidate counts and therefore latency even everywhere. I do not know what the right target is without measuring, and I would tune it by looking at the p99 candidate count per query rather than by reasoning about geometry.
Turns a geometry question into a latency question, which is what it actually is.
1. Why is a fixed cell size wrong in both dense and sparse areas?
2. What does rounding the query coordinates before building the cache key actually achieve?
3. Same design, but the places are now food delivery couriers moving through the city. What changes first?
Worth memorising
200 million places is a few gigabytes of index, so every node holds all of it and nothing is sharded.
Cell count grows with the square of the radius: 5km is about 20 cells, 25km is about 500.
Round the query location to about 100 metres. That one line is your cache hit ratio.
Filter with cells, rank with ETA, hydrate twenty rows. Cheap, then expensive, then tiny.
Say this in 60 seconds
A proximity service is the mirror image of a ride hailing index. The data barely changes and is read constantly, so everything is precomputed. Two hundred million places is only a few gigabytes of geographic index, which means every search node holds a full copy and a search never leaves the machine, removing fan out and hot shards entirely. A query rounds its coordinates to about a hundred metres first, which is the single most valuable decision on the page because it turns an unbounded cache key space into a bounded one. Then it reads the cells covering the radius, ANDs a category bitmap, computes true distance on what survives, ranks a few hundred candidates on distance, quality and whether they are open, and hydrates only the top twenty from the place store. Cell size adapts to density so candidate counts stay even, because one fixed size is wrong in Manhattan and wrong in rural Rajasthan for opposite reasons. Index builds run every few minutes, with one exception: a permanently closed business is removed immediately through a fast path, because additions can wait and removals cannot.