The thing the number was trying to tell me
The API sits on top of two Postgres databases with PostGIS. One holds the places people have contributed and enriched; it's small. The other is a reference layer imported from Overture Maps: 72.8 million rows. The map's "search this area" feature draws a bounding box from the current view and asks that big table what falls inside it.
My first instinct was the boring one: 72 million rows is a lot of rows, maybe this is just slow, maybe I should shrink the default search area or throw a loading spinner at it. I'm a little embarrassed to admit I spent a few minutes on that path before stopping to actually think about the number.
Because the number doesn't make sense for "too much data." A two-kilometer box in a city has, what, a few hundred places in it? If the query were doing work proportional to the answer, it would come back in milliseconds. The only way a tiny box takes a hundred seconds is if the query is doing work proportional to the whole table — reading all 72.8 million rows every time, regardless of how small the question is.
Latency that doesn't shrink when you shrink the input isn't "slow." It's a sequential scan wearing a trench coat.
Confirming it without guessing
The nice thing about having three search endpoints is that I had a control group. Radius search — "everything within 500 meters of this point" — was fine, a couple of seconds cold. Text search was fine. Only the viewport box was dying. Same table, same region, same database. So whatever was wrong lived in the query, not the data.
So I put the two queries next to each other and read them like a diff. Radius used a geography distance function. Viewport did this:
WHERE geometry::geometry && ST_MakeEnvelope(:sw_lng, :sw_lat, :ne_lng, :ne_lat, 4326)
And there it was, hiding in plain sight: geometry::geometry.
The column is typed geography(POINT, 4326) — PostGIS's spheroidal type, the one that knows the Earth is round. It has a GiST index built for exactly this kind of spatial lookup. But geometry and geography are different types in PostGIS, with different operators and, crucially, different index support. By casting the column to geometry and using the planar && overlap operator, the query was asking Postgres to filter on an expression the geography index knows nothing about. There's no index on geometry::geometry. So the planner shrugged and scanned everything. All of it. On every pan.
The part that still makes me laugh: that exact cast is correct a few lines up, in the SELECT.
SELECT ST_Y(geometry::geometry) AS latitude,
ST_X(geometry::geometry) AS longitude
ST_X and ST_Y only work on geometry, so you have to cast there — and it costs nothing, because it only runs on the handful of rows you've already found. The same three characters that are free in the projection are catastrophic in the filter. I'd bet money it got pasted from one into the other by someone (me, plausibly) who saw it working and didn't think twice.
The fix is the diagnosis, restated
Once you can name the problem, the fix writes itself. Don't drag the column out of the type its index lives in — bring the envelope into that type instead:
WHERE ST_Intersects(geometry, ST_MakeEnvelope(:sw_lng, :sw_lat, :ne_lng, :ne_lat, 4326)::geography)
Now both sides are geography, ST_Intersects is happy to lean on the geography GiST index, and the question gets answered by touching only the rows that could possibly matter.
I didn't want to trust a hunch on a fix, so I ran EXPLAIN ANALYZE on both forms against the real 72.8-million-row table. The new query came back as an Index Scan using idx_overture_geometry in 162 milliseconds. The old query I had to abort — I'd put a 20-second statement timeout on it just so my own test would terminate, and it blew straight through that into the sequential scan it had always been. The timeout was the proof.
While I was in there, I found the same mistake in the small database's viewport query — location::geometry && …, identical shape. Harmless today, because that table has a few rows. A trap set for whoever grows it. Fixed both, shipped them together, attached the EXPLAIN output to the change so the next person doesn't have to take my word for it.
What changed, and what stuck
End to end, viewport search went from over a hundred seconds to about 0.28 seconds — and a deliberately large box, sixteen kilometers across returning two hundred results, lands in 0.36. The map became the thing I'd been trying to build the whole time: drag it and the pins just keep up. The best part was deleting code — all those workarounds I'd been about to add, the forced minimum zoom, the artificially tiny default area, none of them were ever the answer. "The area is too big" was never true. The query was reading the whole planet to show you one neighborhood.
A type cast on an indexed column in a WHERE clause silently turns the index off. Nothing errors, the results are still correct, the tests still pass — it just gets quietly, exponentially worse as the table grows, which makes it invisible in development and brutal in production. If you must cast, cast the literal to match the column, never the column to match the literal.
And let the size of a number tell you what kind of problem you have before you start optimizing. A hundred seconds for a tiny query isn't a tuning problem, it's a "you're doing unbounded work" problem, and those have completely different fixes. I almost shrank the search area. The real bug was three characters away the whole time.