Measure before you migrate. I recreated your table with 40k fake rows and ran the bounding box of the default map view:
Two things stand out. The query is 14 ms, so Postgres is not your nine seconds. And at the default zoom the box covers the whole country, so it returns 39,812 rows of select *, around 16 MB of JSON, which the browser then turns into 39,812 markers. That's where the time goes, and why the second load is faster (the response is cached).
In order:
- Select only
id, species, lat, lng. - Below a zoom level, don't return points at all. Return counts per grid cell:
group by round(lat::numeric, 1), round(lng::numeric, 1). - Cluster what's left on the client.
PostGIS with a GiST index, as Pete says, is the right move once the box is small and the table keeps growing. I'd use geometry rather than geography for a plain bounding box, the && operator maps straight onto the index. But an index won't make a query that returns every row any faster.
betweenon lat and lng with no index is a sequential scan every time. PostGIS with a GiST index on a geography column, and cluster the markers on the client.