# Half a Million Points on a Phone

Velvynote renders a world map of user notes on hardware that is mostly a battery with a screen. The map was never the hard part — the transitions between zoom levels were.

Author: Konstantinos Papadopoulos (https://knspap.com)
Published: 2026-07-18
Reading time: 3 min (466 words)
Tags: Geospatial, Performance
Canonical URL: https://knspap.com/essays/half-a-million-points-on-a-phone

---

The pitch for Velvynote is simple enough to fit in a sentence: leave a note somewhere real, and find what strangers left in the same place. The engineering consequence of that sentence is that a phone in a train tunnel has to draw a map of the entire planet and feel like it is not working hard.

## The naive version, and why it dies

The first build fetched every note in the viewport and dropped a DOM marker on each one. This works beautifully in a demo, where the dataset is forty notes you made yourself. It falls over the moment a city has a thousand.

Markers are the expensive part. Each one is a positioned element the browser has to lay out, composite, and reposition on every frame of a pan. A thousand of them turns a sixty-hertz gesture into a slideshow, and no amount of `will-change` rescues it.

## Move the work into the map

MapLibre clusters natively, on the GPU side of the fence, and the fix was to stop treating clustering as something the application does:

```js
map.addSource("memories", {
  type: "geojson",
  data: featureCollection,
  cluster: true,
  clusterMaxZoom: 13,
  clusterRadius: 60,
});
```

Three numbers, and the frame budget came back. `clusterRadius: 60` is the one worth arguing about — it is measured in screen pixels, not metres, which means the grouping stays visually consistent as you zoom, and two notes in the same building never render as two overlapping dots.

## Don't fetch what nobody can see

The second constraint is the network. Below zoom 8, the viewport covers a continent, and a continent's worth of notes is both useless to look at and expensive to move:

```sql
-- get_memories_for_viewport(bbox, limit)
select id, st_asgeojson(location)::json as geometry, preview
from memories
where location && st_makeenvelope($1, $2, $3, $4, 4326)
order by created_at desc
limit 500;
```

The bounding-box operator `&&` hits the GiST index rather than scanning; the `limit 500` is the honest admission that nobody reads five hundred notes at once. Below zoom 8 the client does not call at all. There is nothing to show at that scale that a cluster count would not show better.

> The performance work that mattered was not making things faster. It was deciding what never needed to happen.

## Where the feel actually comes from

With the frame budget recovered, the remaining work was entirely perceptual. A cluster that vanishes and reappears as individual points reads as a bug. A cluster that expands, with the children easing outward from where the parent stood, reads as the map explaining itself.

That transition is maybe forty lines. It is also the thing every single person has mentioned after using it — which is a fair summary of how the effort and the credit distribute in interface work.
