← all topics Dating geo board · say it in this order 1 Problem2 Entities + API 3 High-level design4 Deep dives 5 Final + levelsreset

Design the geolocation half of Tinder or Bumble

10M daily users · 2,300 location writes a second · 1,200 deck builds a second · Hello Interview flow, board rules, one idea per line
SAY THIS SENTENCE FIRST

"The interesting thing about dating geo is that almost nothing about it is a hard real-time problem. A location fifteen minutes old is perfectly good, because nobody's dating radius moves in fifteen minutes, and that single fact makes the write path lossy, unordered and cheap. What is actually hard is three things: density varies by four orders of magnitude so a fixed radius is wrong at both ends, a circle is not a cell so the index over-fetches and then measures, and exact distance is a coordinate, which means the number that makes the product work is also the number that can find someone's apartment."

STEP 1 OF 5

Understanding the problem

5 min

Functional Requirements

  1. A user's location should follow them as they move, without draining the battery.
  2. A user should get a deck of nearby candidates that respects their distance preference.
  3. The distance shown should be useful to a dater and useless to a stalker.
  4. It should work in Manhattan and in rural Montana without two different systems.

Below the line (out of scope):

  • Swipes and mutual matching. It is a like write plus a reciprocal check, it is cheap, and it never touches geo. Cutting it explicitly is what buys the time to go deep on the part that was actually asked about.
  • Recommendation ranking and the ML behind who appears first. Geo produces the candidate SET; ranking orders it, and that is a different hour.
  • Chat, photo storage and moderation, payments and boosts.
  • Travel mode stays in scope as a constraint, because a user who sets their location to another city breaks the assumption that location follows the device, and the design has to survive it.

Non-Functional Requirements

  • Say the staleness tolerance first, because it is the affordance the whole design spends.
    • A location fifteen minutes old is fine. Nobody's set of plausible dates changes because they walked three blocks.
    • So the write path can lose updates, reorder them, and lag, and none of it matters. That is unusual and it should be stated as a decision rather than assumed.
    • The contrast worth drawing: a ride-hailing app needs your position to the second, which is why its design looks nothing like this one even though both are "geospatial".
  • Then the three rates, because they are not close to each other.
    • Location writes: about 2,300 a second. Small, and the goal is to make it smaller rather than to serve it faster.
    • Deck builds: about 1,200 a second. This is the only expensive read in the system.
    • Swipes: about 11,500 a second, and they must cost ZERO geo work, because the deck was already built.
    • The shape of the answer falls out of that: the expensive thing happens 1,200 times a second, so it can afford to be clever.
  • Density is the requirement people forget, and it is the one that breaks naive designs.
    • Manhattan is about 28,000 people per square kilometre. Rural Montana is about 2.7. That is four orders of magnitude.
    • A 5 km circle covers 78 square kilometres either way: about 2.2M people in one and about 200 in the other.
    • One fixed radius therefore returns a crowd you throw away, or an empty product. Both are failures.
  • Deck latency p99 under about 300 ms. It is the app-open path, so it is the product's first impression and there is nothing to hide it behind.
  • Safety is a functional requirement wearing a non-functional hat. Precise distance is a coordinate in disguise, and dating apps have been trilaterated in the wild. The precision of the number is a safety decision, not a UX preference.
  • Consistency requirements are close to nil, and saying so is worth a sentence: there is no correctness problem if two users see slightly different distances, or if someone appears in a deck moments after going offline.
DONE WHEN: the interviewer has heard "fifteen minutes stale is fine" and "density spans four orders of magnitude". Those two license the cheap write path and the adaptive read.
STEP 2 OF 5

Entities + API

5 min

Defining the Core Entities

  • User is the profile plus the preferences that bound every query: maxDistanceKm, age range, and discoverable. The discoverable flag is load-bearing, because turning it off has to remove someone from the index rather than filter them at read time.
  • Location is deliberately not a stored history: userId, cellId, lastSeenAt. The raw fix is used to compute the cell and then thrown away.
    • Storing a location trail is a product decision nobody asked for and a liability everybody inherits. If it is not needed, not keeping it is the design.
  • GeoIndex entry is the index itself: cellId to a set of user ids. That is the whole structure, and it is small: 10M users at a few tens of bytes is under a gigabyte.
  • Deck is a materialised list: userId to an ordered list of candidate ids with a TTL. It exists so the swipe path never queries anything spatial.
  • ExclusionSet is who this user has already seen. It grows forever and is checked on every deck build, which makes it a real data-structure decision rather than a footnote.

API or System Interface

POST   /v1/location            // { lat, lon, accuracy } -> 204, no body

GET    /v1/deck?cursor=         // -> { candidates: [ { userId, photos, bio,
                               //        distance: "under 2 km" } ], cursor }

PUT    /v1/preferences         // { maxDistanceKm, ageRange, discoverable }
POST   /v1/travel              // { cityId } -> sets an explicit location, in scope as a constraint
  • The location write returns 204 with no body, and that is a design statement. Nothing reads its own write, there is nothing useful to return, and an empty response keeps the cheapest call in the system cheap.
  • The deck returns a DISTANCE STRING, not a number. "under 2 km" cannot be trilaterated; 1.24 can. Making it a string in the response shape means no client, no analytics event and no debug view can accidentally leak precision later.
  • No endpoint anywhere returns another user's latitude and longitude. Say it as an invariant, because the leak is usually not the main endpoint: it is a debug field, an analytics payload, or a photo's EXIF data.
  • The deck is cursor-paginated, not offset-paginated, because the underlying candidate set is changing under you and an offset would skip and repeat people.
  • Preferences are a write, not a query parameter, so the deck builder can use them to precompute rather than being handed them at read time.
DONE WHEN: you have said "204, nothing reads its own write" and "distance is a string, on purpose".
STEP 3 OF 5

High-level design

10 min, end to end, no dives yet

1) Location follows the user, cheaply

dg-hi1
  • The client decides when to send, and that is where the win is.
    • Both platforms expose a significant-change API that wakes the app when the user has actually moved a few hundred metres, rather than on a timer.
    • A one-hertz GPS loop on 10M devices is 10M writes a second and a flat battery by lunchtime. The product would die long before the index did.
    • So the design goal on this path is to make the write COUNT smaller, not to serve a large count faster.
  • The write is fire and forget. 204, no body, no read-your-writes, no ordering guarantee. If two updates arrive out of order the loser is a location from four minutes ago, which is inside the staleness budget anyway.
  • All the service does is move a user between cells and stamp lastSeenAt. The raw coordinate is used to compute a cell and is not kept.
  • Age out dormant users, or the index quietly rots. Someone who has not opened the app in a month is a ghost in the deck: they widen everyone's radius while never replying. A stale index is worse than a small one.

2) The index: cells, not radii

dg-hi2
  • The index is a map from cell id to a set of user ids. Geohash, S2 or H3 all give you the same shape; what matters is that a cell id is a prefix-comparable key, so "everyone near here" becomes a set of key lookups rather than a scan.
  • A circle is not a cell, so the query over-fetches on purpose.
    • Cover the query circle with the cells that overlap it, read everyone in those cells, then measure exact distance and drop the corners the circle never reached.
    • Over-fetching is cheap and filtering is exact. Trying to make the index answer the circle precisely is the wrong instinct.
  • The boundary bug is the one everybody ships once, so say it before you are asked. Two people forty metres apart can sit either side of a cell line, share no prefix, and never see each other. You must query the eight neighbouring cells, always.
  • Cell size is the tuning knob and it has one tradeoff: coarser cells mean more over-fetch and fewer lookups; finer cells mean less waste and more neighbours to read. Pick per density rather than globally, which is section 3.
  • Choose the cell level coarser than your GPS error. An urban fix can be 50 metres off, so indexing at 10-metre precision is inventing accuracy the input never had.

3) Building a deck that works at both extremes

dg-hi3
  • Query for a target COUNT, not for a distance. The user wants a deck; the radius is the means. Aim for a few hundred candidates and let the radius be whatever produces them.
  • Expand by cell level, not by re-running a circle. Each level out is roughly four times the area, so a handful of lookups covers an enormous range of densities.
    • Do the arithmetic rather than waving at it. At the implied dater rate of about 1.4 percent, one square kilometre of Manhattan holds around 380 candidates, so the first level already satisfies the target.
    • Rural Montana holds about 0.025 candidates per square kilometre, so 300 of them needs roughly 12,000 square kilometres, which is a radius near 60 km and six or seven levels out.
    • Six or seven lookups is still cheap. The point is that the SAME loop serves both, not that both finish quickly.
  • Cap the expansion and be honest about the result. Without a maximum a lonely user eventually queries a continent. Past the cap the correct product answer is "there is nobody here", not a match three states away presented as if it were local.
  • Exclude everyone already swiped, and treat that set as a real problem. It only grows, it is checked on every build, and a heavy user has tens of thousands of entries. A compressed bitmap keeps it to kilobytes and makes the check a cheap intersection.
  • Cache the deck, because that is what makes the swipe path free. Build about a hundred candidates on app open, serve swipes from it, and rebuild when it runs low or the TTL expires. 11,500 swipes a second then cost nothing spatial at all.

4) A distance that cannot be triangulated

dg-hi4
  • State the attack plainly, because it is documented and it has happened to real apps. Spoof your own position three times, read the reported distance each time, and intersect the three circles. Three queries turn a distance into an address.
  • Precision is the vulnerability, not the distance itself. A dater needs to know roughly how far; they never need two decimal places.
  • Snap to a grid BEFORE computing, and do it deterministically.
    • Both users are moved to their cell centre and the distance is computed from there, so the same pair always yields the same answer.
    • Random jitter does not work and it is worth saying why: query fifty times and average the noise away. Only deterministic quantization survives repetition.
  • Widen the buckets with distance. A kilometre of precision matters at two kilometres and is meaningless at fifty, while costing the same privacy either way.
  • The product cost is real and you should own it: "under 2 km" is a worse experience than "1.2 km". It is still the right trade, and being able to say why is the point of the dive.
DONE WHEN: all four requirements have a path, and you have said out loud that the swipe path never touches geo.
STEP 4 OF 5

Potential deep dives

~20 min, interviewer steers
TRIGGER: "how do you find people nearby" / "what index"

1) What actually answers "who is near me"?

CELLS AS KEYS · OVER-FETCH THEN MEASURE · ALWAYS THE NEIGHBOURS
Bad Solution: a bounding box in SQL
  • WHERE lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?
    • A B-tree on latitude and a B-tree on longitude cannot be used together: the database picks one, gets a band across the planet, and filters the rest by hand.
    • At 10M rows and 1,200 queries a second that is a scan per query and the database falls over.
    • It is also wrong at the poles and across the antimeridian, which is a smaller problem but the same root cause: latitude and longitude are not a plane.
Good Solution: geohash prefixes, or Redis GEO
  • Encode the position as a geohash and match on a prefix, which is exactly what Redis GEO does with a sorted set underneath. This genuinely works and it is the right family of answer.
    • Two gaps, and an interviewer will find both. A prefix match silently misses the neighbouring cells, so people forty metres apart never meet.
    • And the cell size is fixed globally, which is the density problem in dive 2: one level cannot serve Manhattan and Montana.
Great Solution: a covering set of cells, plus neighbours, plus an exact filter
  • Turn the circle into a covering set of cell ids and read the union. S2 and H3 both do this properly; H3's hexagons have the pleasant property that all six neighbours are equidistant, which removes a class of corner cases.
  • Always include the eight surrounding cells. This is the boundary bug, it is invisible in testing because your test users are never forty metres apart across a line, and it is the single most common defect in this design.
  • Then filter exactly. Haversine on a few hundred candidates is microseconds, so the index only has to be a good superset, never a precise answer.
  • Keep the whole thing in memory and say the size out loud: 10M users of cell id plus user id is well under a gigabyte, so this is a Redis-shaped problem, not a database-shaped one. That number is what makes the rest of the design affordable.
  • Shard by cell prefix so a region's users live together and a query touches one shard. The natural consequence is that dense regions get hot shards, which is the next dive rather than a surprise.
TRIGGER: "what about a rural user" / "Manhattan vs a small town"

2) Density that spans four orders of magnitude

TARGET A COUNT, NOT A RADIUS · STEP OUT BY LEVEL · CAP IT AND SAY SO
Bad Solution: one radius for everybody
  • Query 5 km, ship it.
    • Manhattan returns tens of thousands of candidates you immediately throw away, and the deck build blows its latency budget sorting people nobody will see.
    • Montana returns two, then one, then none. The product is empty and the user leaves.
    • The same query is simultaneously too expensive and useless, which is a good sign the parameter is the wrong one.
Good Solution: let the user pick the radius
  • Expose a distance slider and honour it. Necessary as a product feature, and it does help the rural case.
    • But it makes the user solve an engineering problem: nobody knows whether to ask for 50 km or 200 km, and they will drag it around until something appears.
    • It also does not bound the work: a rural user dragging to 500 km issues exactly the enormous query you were trying to avoid.
Great Solution: the radius is an output, and the level is the lever
  • The query is "give me about three hundred candidates". Start at a level sized for local density, and step out one level at a time until the count is met.
  • Each step out is roughly four times the area, so it converges in a handful of lookups even from a very sparse start. Manhattan is satisfied at the first level with roughly 380 candidates in a single square kilometre; Montana needs about 12,000 square kilometres for the same 300, which is six or seven steps and a radius near 60 km.
  • Store an estimated density per cell from the index itself, and start at the right level rather than discovering it. That turns the common case into a single lookup instead of a walk.
  • The user's slider becomes a filter, not the query. They cap how far they will travel; the system decides how far it must look to fill a deck.
  • Cap the expansion, and let the product be honest past the cap. "There is nobody nearby right now" is a better answer than someone three states away presented as local, and it is also what stops one lonely user issuing a continental query.
  • Hot shards are the consequence and they need naming: a dense city's cells are read constantly, so those shards get read replicas. It is a read-only, cacheable, small dataset, which is the easiest kind of hotspot to fix.
TRIGGER: "how often does the phone report" / "what about battery"

3) Moving users, and how little you need to know

SIGNIFICANT CHANGE, NOT A TIMER · STALENESS IS THE BUDGET · AGE OUT THE GHOSTS
Bad Solution: stream the GPS
  • Subscribe to location updates and post each one.
    • 10M devices at one hertz is 10M writes a second, which is four thousand times the traffic the product actually needs.
    • It is also the fastest way to get uninstalled: continuous GPS is one of the few things a user can feel in their battery.
Good Solution: throttle to a fixed interval
  • Send a fix every five minutes while the app is open. Much better, and it is where most designs land.
    • It still writes constantly for the large majority of users who have not moved at all.
    • And it stops entirely when the app is backgrounded, which is exactly when someone commutes across town.
Great Solution: let the OS decide, and spend the staleness budget deliberately
  • Use the significant-change API. The platform wakes the app when the user has genuinely moved, using cell towers and wifi rather than GPS, at a tiny fraction of the power.
  • Only write when the CELL changes, which the client can decide for itself. Moving within a cell changes nothing in the index, so the write is pure waste.
  • Say the staleness budget out loud as the thing being spent: fifteen minutes of lag is invisible to the product, and it is what buys a lossy, unordered, cheap write path. This is the sentence to lead the dive with.
  • Age out users who stop appearing. Someone dormant for thirty days is removed from the index, which keeps the ghost problem from quietly widening everyone's radius.
  • Handle the teleport, because it is not always travel mode. A user who appears 3,000 km from their last position is either on a plane or spoofing. Accept it, but treat a sudden jump as a signal for the safety systems rather than as an ordinary update.
TRIGGER: "can someone find where I live" / "is distance safe"

4) Trilateration, and why jitter is not the answer

THREE QUERIES IS AN ADDRESS · SNAP BEFORE MEASURING · DETERMINISTIC, NEVER RANDOM
Bad Solution: return the exact distance
  • "1.24 km away" is friendly and precise.
    • Spoof your GPS to position A and read the distance. Move to B, read again. Then C. Three circles intersect at one point, and that point is a home address.
    • This is not hypothetical: it has been demonstrated against real dating apps more than once, and the fix each time was to stop returning precision.
    • Returning coordinates directly is the same bug with fewer steps, and it has shipped too, usually in an API response the client never displayed.
Good Solution: round the distance, or add noise
  • Round to the nearest kilometre, or add a small random offset. Both feel like they should work.
    • Rounding alone still leaks: sample from many positions and the boundaries where the rounded value flips trace out the true circle.
    • Random noise is worse, because it is defeated by repetition. Query fifty times, take the mean, and the noise cancels while the signal stays.
    • The flaw in both is the same: the attacker controls the number of samples, so anything that varies per query averages out.
Great Solution: quantize the position deterministically, before you measure
  • Snap both users to a grid cell centre and compute the distance from those centres. The output is now a property of the cell pair, not of the true positions.
  • Determinism is the whole point. The same pair always produces the same answer, so repeated sampling reveals nothing new and averaging has nothing to average.
  • Report a bucket, not a number, and widen the buckets with distance. Close up, one-kilometre steps; far away, ten-kilometre steps, because far-away precision has no product value and identical privacy cost.
  • Make it structural rather than a rule people remember: the distance field is a string in the API type, so no client, analytics event or debug endpoint can leak a float later. Strip EXIF from photos for the same reason.
  • Cover the other leak: the ordering of the deck. If candidates come back strictly sorted by true distance, the ORDER re-encodes the precision you just removed. Sort within a bucket by something else.
  • Say what it costs. The product is slightly worse, the safety property is worth it, and there is a floor below which you should simply refuse to be precise regardless of what the design team wants.
TRIGGER: "what happens on a swipe" / "how does the deck stay fresh"

5) The deck, and why swiping is free

PRECOMPUTE ON OPEN · SWIPES NEVER QUERY GEO · THE EXCLUSION SET IS THE REAL PROBLEM
Bad Solution: query on every swipe
  • Fetch the next candidate when the user swipes.
    • 11,500 swipes a second becomes 11,500 spatial queries a second, ten times the deck rate, for a result that has not changed since the last one.
    • It also puts a network round trip in the animation, which is the one place in this product where latency is actually felt.
Good Solution: page the results
  • Fetch twenty at a time and refill in the background. Right instinct, and it fixes the animation.
    • The refill still lands mid-session, so a heavy swiper generates a steady stream of spatial queries anyway.
    • And paging over a set that is changing underneath you repeats and skips people unless the cursor is stable.
Great Solution: build a deck, then forget geo exists
  • Build about a hundred candidates when the app opens and cache the list with a TTL. Every swipe reads from it, so the swipe path has no spatial work in it at all.
  • That is why the rate asymmetry matters: 11,500 swipes a second collapse onto 1,200 deck builds, and the expensive thing now happens an order of magnitude less often than the cheap thing.
  • The exclusion set is the part that is genuinely hard.
    • It only grows, it is consulted on every build, and a heavy user has tens of thousands of entries.
    • A compressed bitmap of user ids keeps it to kilobytes and turns the check into a fast intersection rather than tens of thousands of lookups.
    • A Bloom filter is the tempting alternative and is the wrong trade here: a false positive silently hides a real person forever, and the user can never discover it.
  • Rebuild on the triggers that actually matter: the deck runs low, the TTL expires, the user changes their filters, or they move far enough to change cells. Not on a timer.
  • Accept that the deck is a snapshot. Someone may go offline, change their preferences or swipe you first while they sit in your cached deck. None of that is a correctness problem, and pretending it is leads to a much more expensive design for no product gain.

Three more dives, briefly

  • Travel mode, which breaks the assumption that location follows the device.
    • An explicitly set location is just another cell write, so the index does not care. What changes is that lastSeenAt and the cell no longer agree with the device, so any safety heuristic built on "did they teleport" has to know the difference.
    • It also has to expire, or a user is discoverable in a city they left months ago.
    • The interesting consequence: a user can be in two markets' decks in one day, which matters for anything that reasons about a local population.
  • Borders, oceans and the antimeridian, which is where naive geo breaks.
    • A user in a border city has half their candidate population in another country, another language and another legal regime. The index does not care and the product very much does.
    • Longitude wraps at 180 degrees, so a circle spanning it becomes two ranges. Cell-based indexes handle this natively, which is one more reason not to hand-roll bounding boxes.
    • Distance on a sphere is not distance on a plane. At dating distances the error is small, but the poles and the wrap are where the flat approximation stops being harmless.
  • What changes if the product needs live location, which is the question that reframes everything.
    • A "who is here right now" feature removes the staleness budget, and with it the lossy write path, the cached deck and most of the reason this design is cheap.
    • That is the honest answer to why this looks nothing like a ride-hailing system: not because the geometry is different, but because the freshness requirement is.
    • It also raises the safety stakes sharply, since real-time presence is a much more dangerous signal than an approximate distance.
DONE WHEN: you have opened two or three dives properly, and the staleness budget has been used as a REASON at least twice rather than restated as a fact.
FLASHCARDS · THE FIVE HARDEST PROBESshow all (interview mode)
Two users are 40 metres apart on opposite sides of a cell boundary. What happens?
  • With a naive prefix match, nothing: they share no cell prefix, so neither ever appears in the other's deck.
  • This is the single most common defect in this design, and it is invisible in testing because test users are never 40 metres apart across a line.
  • The fix is to always query the eight neighbouring cells as well as the centre one, so the covering set is a superset of the circle.
  • Then filter exactly with haversine on the few hundred candidates, which is microseconds, so the index only ever has to be a good superset.
  • Hexagonal cells reduce the corner cases because all six neighbours are equidistant, which is a real reason to prefer H3 over a square grid.
Manhattan and rural Montana, same query. What breaks?
  • Density spans four orders of magnitude: about 28,000 people per square kilometre against about 2.7.
  • A 5 km circle is 78 square kilometres either way, so it holds about 2.2M people in one and about 200 in the other.
  • So a fixed radius returns tens of thousands of candidates you throw away, or an empty product. Both are failures of the same parameter.
  • Query for a target count of a few hundred instead, and step out one cell level at a time until it is met. Each level is roughly 4x the area.
  • Manhattan is done at the first level, about 380 candidates in a square kilometre. Montana needs roughly 12,000 square kilometres for 300, so six or seven levels and a radius near 60 km. Same loop, very different answer.
  • Store an estimated density per cell so the common case starts at the right level rather than walking to it.
  • Cap the expansion, and past the cap say "nobody nearby" rather than presenting someone three states away as local.
How does a stalker turn "1.2 km away" into a home address, and how do you stop it?
  • Spoof your own position three times, read the distance each time, and intersect the three circles. They meet at one point.
  • Three queries is all it takes, and it has been demonstrated against real dating apps more than once.
  • Rounding alone still leaks, because sampling many positions traces out the boundary where the rounded value flips.
  • Random jitter is worse: the attacker controls the sample count, so averaging fifty queries cancels the noise and leaves the signal.
  • The fix is deterministic quantization BEFORE measuring: snap both users to a cell centre, compute from there, so the same pair always returns the same answer and repetition reveals nothing.
  • Report a bucket as a string, widen buckets with distance, never return lat/lon from any endpoint, and do not sort the deck by true distance, because the ordering re-encodes what you just removed.
How often does the phone report its location, and what happens if you get that wrong?
  • Only on significant change, and only when the cell actually changes. Moving within a cell changes nothing in the index, so that write is pure waste.
  • Get it wrong in the expensive direction and a 1 Hz GPS loop on 10M devices is 10M writes a second and a battery the user can feel draining. They uninstall before the index notices.
  • Get it wrong in the cheap direction and only-on-foreground misses the commute across town, which is exactly the movement that matters.
  • The budget being spent is staleness: fifteen minutes of lag is invisible to a dating product, and that is what makes the write path lossy, unordered and cheap.
  • Age out anyone dormant for thirty days, or ghosts quietly widen everyone's radius while never replying.
A user swipes 100 times in two minutes. How many spatial queries is that?
  • Zero. The deck of about a hundred candidates was built when the app opened and cached with a TTL.
  • That is the whole reason the rates are shaped the way they are: 11,500 swipes a second collapse onto about 1,200 deck builds a second.
  • Querying per swipe would put a network round trip inside the animation, which is the one place in this product where latency is actually felt.
  • The deck rebuilds on real triggers: running low, TTL expiry, a filter change, or moving far enough to change cells. Never on a timer.
  • The genuinely hard part is the exclusion set of already-seen users: it only grows, so use a compressed bitmap, and not a Bloom filter, because a false positive hides a real person forever and nobody can discover it.
  • The deck is a snapshot and that is fine: someone going offline or changing filters mid-deck is not a correctness problem.
STEP 5 OF 5

Final design + what is expected at each level

wrap

Final Design

dg-arch
  • The client sends a fix only when the OS reports real movement, and only when that movement crosses a cell, so the write count is small by construction rather than by throttling.
  • The location service answers 204, moves the user between cells, stamps lastSeenAt, and keeps no coordinate history.
  • The index is cell id to a set of user ids, under a gigabyte for 10M users, sharded by cell prefix with read replicas on the dense shards.
  • A deck build covers the circle with cells including the eight neighbours, steps out a level when the count is short, filters exactly by haversine, removes the already-seen bitmap, and caches about a hundred candidates.
  • Swipes read the cached deck, so the highest-rate operation in the product does no spatial work at all.
  • Every distance is computed from grid-snapped positions and returned as a bucketed string, and no endpoint returns another user's coordinates.
  • The invariant worth closing on: a location fifteen minutes old is good enough, and that single tolerance is what pays for the cheap write path, the cached deck and the coarse index. The two things it does not pay for are density and safety, which are the two dives worth having.

What is Expected at Each Level

  • Mid
    • Reaches a geohash or Redis GEO index and a query by radius, which is a working answer.
    • Separates the location write from the candidate read.
    • Usually misses the neighbour-cell boundary bug entirely.
    • Usually treats distance as a display detail rather than a safety property.
  • Senior
    • Handles the boundary correctly and filters exactly after over-fetching.
    • Notices the density problem and adapts the radius or the cell level to it.
    • Caches the deck so the swipe path does no spatial work, and can say why that ratio matters.
    • Throttles location updates deliberately and can defend the interval.
  • Staff
    • Leads with the staleness tolerance and uses it as the reason for at least three separate decisions rather than mentioning it once.
    • Reframes the query from a radius to a target count, and knows the cell level is the lever rather than the circle.
    • Raises trilateration unprompted, and knows why jitter fails where deterministic quantization works.
    • Catches the second-order leak: sorting the deck by true distance re-encodes the precision that bucketing removed.
    • Picks the exclusion-set structure on the right grounds, rejecting a Bloom filter because a false positive is invisible and permanent.
    • Names what the design does not do: the deck is a stale snapshot, ghosts need ageing out, and a live-presence feature would invalidate most of the cheapness.
DONE WHEN: you can point at the final diagram and say, in one breath, fifteen minutes stale is fine, cells not radii, always the neighbours, target a count not a distance, and snap before you measure.