TL;DR: ParkEase's "parking near me" search caches results in Redis, keyed by a ~150m geohash cell, so drivers standing near each other share one cache entry. That's a good cache. It also quietly breaks distance-sorted pagination, because the cached distances were measured from someone else's location. The fix is to have the cursor carry the origin point the distances were measured from. Here's the whole design, plus a few smaller traps along the way.
Part 2 of my series on building ParkEase, a peer-to-peer parking marketplace for India. Part 1 was about stopping double bookings with a Postgres exclusion constraint.
The query
A driver opens the app, and we show active parking spaces within a radius, filtered by vehicle type, duration, price, amenities and rating, sorted by distance, price or rating.
The core of it, using PostGIS with a geography(Point, 4326) column:
export function originPoint(lat: number, lng: number): SQL {
// Note the order: ST_MakePoint takes (x, y) = (lng, lat). Getting this backwards is the classic PostGIS bug.
return sql`ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography`;
}
const conditions = [
sql`s.approval_status = 'active'`,
sql`s.deleted_at IS NULL`,
sql`ST_DWithin(s.location, ${origin}, ${q.radiusM})`, // metres, because geography
sql`${basePrice} IS NOT NULL`,
];
ST_DWithin on a geography column takes metres and uses the GiST index. I also added a partial GiST index that only covers the rows search can ever return:
CREATE INDEX spaces_active_location_gix
ON spaces USING gist (location)
WHERE approval_status = 'active' AND deleted_at IS NULL;
Amenity filters use JSONB containment (@>), so "covered AND cctv" means both, not either. They're backed by a GIN index with jsonb_path_ops.
Pagination: keyset, not OFFSET
Results page with a cursor over a (sort_value, id) tuple:
case 'distance':
return sql`(ST_Distance(s.location, ${cursorOrigin}), s.id) > (${c.value}::float8, ${c.id}::uuid)`;
case 'price':
return sql`(${basePrice}, s.id) > (${c.value}::bigint, ${c.id}::uuid)`;
Row comparisons like (a, b) > (x, y) are one of my favourite Postgres features. You get correct tie-breaking without an OR chain. Adding id as the second key means two spaces at exactly the same distance still page deterministically.
First trap: sort on the exact distance, not the rounded one you display. The UI shows "350m", but if ORDER BY uses the rounded value while the cursor compares the raw ST_Distance, the two disagree near a rounding boundary. Rows get duplicated or dropped between pages.
The cache
Search is read-heavy, and drivers cluster: at a mall, a station, or outside an office tower at 9am. So stage 1 of search (the candidate list) is cached in Redis for 60 seconds:
export const CACHE_CELL_PRECISION = 7; // ~150m x 150m geohash cell
static keyFor(q: SearchSpacesQuery): string {
const cell = geohashEncode(q.lat, q.lng, CACHE_CELL_PRECISION);
return `search:${cell}:${filtersHash(q)}`;
}
filtersHash is a SHA-256 of the canonicalised filters (with sorted amenities, so [cctv, covered] and [covered, cctv] hash the same). It deliberately excludes the exact coordinates. If it included them, two drivers 20m apart would never share an entry, and the whole point of snapping to a cell would be lost.
What's not in the cache: live availability and surge pricing. Availability is computed on every request, and surge has its own shorter-lived key. A cache hit still runs the availability check.
The bug hiding in there
Take two drivers in the same geohash cell, up to ~216m apart (the cell's diagonal):
- Driver A searches. Cache miss. We compute distances from A's location, cache the candidates, and serve page 1.
- Driver B, 150m away, searches 20 seconds later. Cache hit. B gets page 1 with distances measured from A.
- B scrolls. Page 2's query computes
ST_Distancefrom B's location and compares it with the last value from page 1, which was measured from A.
Page 1 and page 2 now disagree about what "farther" means. Depending on which way B is from A, rows between the two orderings get skipped or shown twice. No error, no log line, just a list that's slightly wrong in a way nobody would ever report.
The fix: the cursor carries its origin
Two changes.
1. The cache entry remembers where its distances were measured from:
const cachedEntrySchema = z.object({
origin: z.object({ lat: z.number(), lng: z.number() }),
candidates: z.array(candidateSchema),
});
2. The distance cursor carries that origin, and page 2 continues from it, not from the caller's current position:
export interface SearchCursor {
readonly sortBy: SearchSort;
readonly value: number;
readonly id: string;
readonly origin: { readonly lat: number; readonly lng: number };
}
Now page 2 continues the exact ordering page 1 showed. The distances might be off by up to one cell for driver B. Distance badges round to 50m anyway, and the ordering stays consistent, which matters more.
A cursor is untrusted input
The cursor is base64url JSON that comes back from the client, so it's parsed through a schema before anything reaches SQL:
const cursorPayloadSchema = z.object({
s: z.enum(['distance', 'price', 'rating']),
v: z.number().finite(),
i: z.string().uuid(),
h: z.string(), // hash of the filter set it was issued under
o: z.tuple([z.number().min(-90).max(90), z.number().min(-180).max(180)]),
});
The h field binds the cursor to its filter set. If a client replays a page-2 cursor after changing filters, it would silently skip or repeat rows, so that request gets a 400 INVALID_CURSOR instead.
Smaller things that mattered
- A cache hit and a cache miss go through the same Zod schema. A hit and a miss return identical data, and an entry written by an older version of the code turns into a cache miss instead of a crash.
- Redis being down is just a miss. A cache that can't be read or written makes search slower. It doesn't make it fail.
-
Floating point bites rating filters. Ratings are stored as basis points (4.2★ = 42000). In JavaScript,
4.19 * 10000is41900.00000000001, so a naive>=would exclude a space rated exactly 4.19.Math.round()before comparing fixes it.
Next in the series: how ParkEase handles money. Amounts in paise, basis points, and a ledger table that Postgres won't let anyone edit.
Question for you: how do you paginate results from a shared cache? Do you pin the cursor to the cache entry, or skip caching for paged queries?
Top comments (0)