Insights

28,000 listings on one map with MapLibre and no API bill

28,000 listings on one map with MapLibre and no API bill

A brokerage client wanted every active listing on one map. Not a search box, not a widget: the whole market, placed, on a page a visitor can leave open for twenty minutes. The build that answered it renders with MapLibre GL JS, and nothing in it charges by the page load. It went live at the end of August. The day I measured it for this post it carried 27,703 homes.

Two honest notes before the mechanism. The pin pipeline, the versioning and the cron are in production exactly as described here. The basemap is the part still in transit: the live map draws its streets from a free hosted style today, and the self-hosted PMTiles archive is measured, sized and staged as the answer for the day that changes. I would rather tell you which half is running than let a diagram imply both.

A two-column diagram of the listings map pipeline. On the origin: the feed sync finishes a tick, one fingerprint query using COUNT and BIT_XOR of CRC32 decides whether anything changed, a changed fingerprint streams a new version-stamped GeoJSON one fwrite at a time and renames it into place, and a PMTiles basemap archive is staged beside it. In the browser: the page carries the version string, one request fetches every pin at 2.0 MB over the wire, MapLibre clusters the points with clusterProperties summing price, and basemap tiles would arrive as HTTP range requests into the single PMTiles file once that archive is pointed at. A footnote records that the basemap running today is a free hosted style and that the archive is the measured escape hatch.

What does a 28,000-pin map actually have to do?

The pins have to arrive at once, stay interactive on a mid-range phone, survive a feed that changes every half hour, and carry the furniture the MLS requires on every surface that shows listing data. The file I measured for this post holds 27,703 features: 22,014 of them for sale, the rest pending, and both numbers move with the next sync. On disk it is 10.2 MB of GeoJSON. Over the wire it is 2.0 MB with Brotli, 2.3 MB with gzip.

The catalog page stays the list, and the list is the fallback. It renders first in the DOM, it works with JavaScript off, and the pins are never focusable, because tabbing through 27,703 circles is not accessibility. The feed underneath all of it is a RESO Web API sync, which has its own set of surprises and its own post about the feed layer.

Why not just use the Google Maps API?

Because a listings map is exactly the page a meter punishes. Google's own pricing page, updated 1 September 2026, puts Dynamic Maps Essentials at 10,000 free calls a month and then $7.00 per 1,000 from 10,001 to 100,000. A site doing 50,000 map loads in a month is $280 for that month, and the free allowance does not pool across products, so each API brings its own 10,000 and its own bill.

That is not an argument against Google. It is an argument about shape. A single agent's site cannot carry a cost that grows with curiosity, and the person most likely to open the map fifteen times is the buyer you want. The renderer, the basemap and the pins in this build are all things I can put on the same host as the site, so the marginal cost of the fifteenth visit is bandwidth and nothing else. The same instinct runs through an earlier build for a realtor and through the IDX product this map now belongs to.

What is PMTiles, and why does it need no tile server?

PMTiles is a single-file archive of map tiles that the browser reads with HTTP range requests, so the server never has to know what a tile is. It serves a static file, and the client asks for the bytes it wants. Protomaps' documentation describes readers that "fetch only the relevant tile or metadata inside a PMTiles archive on-demand," designed so that panning costs "at most two cacheable intermediate requests."

The v3 specification lays the file out in five sections: a fixed 127-byte header, a root directory, JSON metadata, optional leaf directories, and the tile data itself, with tile IDs following a Hilbert curve so that neighbours on the map are neighbours in the file. The number that made the format practical arrived with version 3. Protomaps' release write-up from 31 October 2022 puts it plainly:

"Spec version 2 would always issue a 512 kilobyte initial request; version 3 reduces this to 16 kilobytes."

A diagram of one PMTiles archive drawn as a horizontal bar with five labelled sections: a 127-byte header, a root directory of about 16 kilobytes, JSON metadata, leaf directories mapping tile ids to offsets, and the remaining 230-odd megabytes of Hilbert-ordered tile data. Below it, three numbered HTTP range requests: bytes 0 to 16383 for the header and root directory, a few kilobytes for a leaf directory, and a final range for the tile itself.

How do you cut a statewide extract?

One command, and you never download the planet. The pmtiles CLI can read a remote archive over HTTP and pull only the ranges your bounding box needs, which is the same trick the browser plays, aimed at a build machine:

pmtiles extract https://<daily-build>.pmtiles oregon.pmtiles \
  --bbox=-124.6,41.9,-116.4,46.3 \
  --maxzoom=14

Protomaps publishes a daily planet build, roughly 120 GB through zoom 15, under the Open Database License. Its documentation warns that "each additional zoom level roughly doubles the size of the file," and my dry run against the 28 August 2026 build agreed: Oregon came out at 231 MB through zoom 14 and 46 MB through zoom 12. Zoom 14 is street level, which is where price bubbles want to live, so 231 MB is the number I plan around.

Registering it with MapLibre is four lines, from the pmtiles package:

import { Protocol } from "pmtiles";
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
// then, in the style: "url": "pmtiles://https://client-site.com/oregon.pmtiles"

Two things decide whether this works in production. The host has to answer range requests, and whatever CDN sits in front has to be willing to cache an object that size. Cloudflare documents a 512 MB maximum cacheable file on the Free, Pro and Business plans, which a statewide extract fits inside and a planet file does not. Until that archive is uploaded, the live map draws from OpenFreeMap, which is free, keyless, explicitly allows commercial use, and is run by one person on donations. Free and keyless is why it is serving. One person on donations is why the extract exists.

Why is the pin data a file and not an endpoint?

A viewport API feels like the obvious answer and is the wrong one here. Every pan becomes a PHP and MySQL round trip that no cache can help with, on shared hosting, for data that is identical for every visitor. So the cron writes a file instead, and only when it has to. After each sync tick, one fingerprint query decides:

SELECT COUNT(*),
       BIT_XOR(CRC32(CONCAT_WS(',', listing_key, status,
              COALESCE(price,0), COALESCE(price_high,0),
              COALESCE(lat,0), COALESCE(lng,0))))
  FROM mls_listings
 WHERE status NOT IN (:closed) AND show_address = 1
   AND lat IS NOT NULL AND lng IS NOT NULL

BIT_XOR over a per-row CRC catches an edit and a deletion, which a MAX(modified_at) quietly misses. If the fingerprint has not moved, the cron exits without writing a byte, and nothing downstream has to expire. If it has moved, the generator streams the new file one fwrite per row on an unbuffered cursor, so memory stays flat no matter how large the table grows, writes to a temporary name, and finishes with an atomic rename(). The version string is a hash, the URL carries it, and the response is Cache-Control: public, max-age=31536000, immutable. The three newest versions stay on disk, so a tab that has been open since the last rebuild never 404s its own data.

What the wire measured, and the cookie I found in it

My estimate was low by about five times per listing, and it was wrong in a way worth naming. Sizing this in August, I generated 22,000 synthetic points with five properties each and measured 343 KB gzipped, which is roughly 16 bytes a listing. The real file gzips to 2.3 MB across 27,703 features, about 83 bytes each, because a real feature is not five numbers. It carries the address, the city, beds, baths, square feet, the cover image URL, the ZIP, the list date, two flags, and the full listing courtesy line, so that tapping a pin can open a complete card without a second request. Measure the file you are going to ship, with the properties the card actually needs.

Then, checking headers for this post, the edge told on itself: cf-cache-status: BYPASS, on a URL explicitly marked immutable. The response also carried a Set-Cookie header with a session id in it, because the data route runs through the application bootstrap and the bootstrap starts a session. Cloudflare's default cache behavior documentation is unambiguous: it does not cache the resource when "the Set-Cookie header exists." An immutable URL is necessary and not sufficient. The file is still cached hard in every browser that has it, so visitors were never slow, but the edge was doing nothing and the origin was paying for it. The fix is to answer that route before any session starts, which is a smaller change than the measurement that found it.

Clusters, and what a price bubble is allowed to say

Clustering is a source setting, not a library. MapLibre's GeoJSON source takes cluster: true, a radius (48 here), a clusterMaxZoom of 13, and this, which is the part people miss:

clusterProperties: { psum: ['+', ['get', 'p']] }

The style spec describes clusterProperties as "aggregating values from clustered points," so the sum of prices rides along inside each cluster and the disc can print an average without a second pass over 27,703 features. Below the cluster zoom, each home becomes a symbol layer label with a thick halo, which is a price bubble that costs one glyph rather than one DOM node.

What the bubble says is a compliance question, not a design one. A bubble shows one number, so it prints the maximum of the price range rather than the low end. Status words come from the MLS's vocabulary, not the feed's field names. A listing whose address is withheld gets no pin and never enters the file at all. Every pin card carries the source mark and the untruncated listing courtesy line, and the map keeps its attribution and its data timestamp visible at all times, which is why the corner of the screenshot below is not empty.

A browser window showing the live listings map at its default statewide fit: cream cluster discs with a muted pink ring, each printing a count above an average price, from avg $308K up to avg $5.8M, over a muted vector basemap, with single homes drawn as price bubbles and the required MLS attribution and data timestamp sitting in the lower corner.
Clusters print a count and an average, single listings print a price, and the required attribution and data timestamp stay on the canvas. The market count on the canvas moves with every sync, so it drifts from the figures above between one measurement and the next. On a phone the same market opens list-first instead, with a Map pill floating over the cards.

Why does this ship on MapLibre 5 and not 6?

Because version 6 stopped publishing the build my page was loading. The v5 to v6 migration guide states it directly: "MapLibre GL JS v6 ships as ES modules only. The UMD bundle, the separate CSP build, and the CommonJS [...] entry from v5 are all gone." The same release removed WebGL1 support and made WebGL2 mandatory. Version 6.0.0 was published on 22 July 2026; 5.24.0, the last line with a classic script build, on 23 April 2026.

I found this the way you usually find these things. My vendor folder still contains a 49-byte file whose entire content is Not found: /[email protected]/dist/maplibre-gl.js, which is what a CDN says when you ask for a bundle that is not published any more. The module build I fetched instead did not come up in my WebKit check that evening, and I did not chase the cause: 5.24.0 loads as a classic script, keeps a WebGL1 path for older iPhones, and pinning it was a one-line decision on a night that had a deploy in it. The removal of the UMD bundle is documented and certain. The import failure is mine to reproduce properly before I call it anything but unexplained.

One more reason to pin and to keep a single map instance: issue #7667, opened 21 May 2026 and still open, reports iOS Safari killing the page after repeated map reloads, with over 100 MB retained across repeated reloads, against 5.24.0 and 4.7.1. That is a memory bug, and the cheapest defence is to never create a second map.

One thumb on a map

The phone layout is list-first with a Map pill, not map-first. The map mounts once and parks off screen instead of unmounting, which is both faster and a hedge against the leak above. Panels translate rather than animate height, heights are in dvh, filters are native selects and 44px targets inside a sheet, and user-scalable=0 never appears, because pinching a map is the one gesture nobody should have to argue for. Reduced motion turns off the fly-to easing. It is the same discipline as any other piece of front-end craft, including the scroll-scrubbed video work: the phone is the real device, and everything else is a larger version of it.

Next on this map: the extract, uploaded and pointed at, so the basemap stops depending on anyone's donations; the session cookie off the data route, so the edge does the job the immutable header already asked it to do. And an open question I have not settled, which is whether zoom 14 is worth five times the bytes of zoom 12 for a market where most of the listings sit in one metro.

Common questions

Does a MapLibre map need an API key?

No. MapLibre GL JS is BSD-licensed and needs no key. What usually carries a key is the basemap, and that can be answered with a free keyless hosted style or with a PMTiles archive you host yourself, which is one static file served over HTTP range requests.

What is PMTiles?

A single-file archive of map tiles. The v3 specification lays it out as a 127-byte header, a root directory, JSON metadata, optional leaf directories and the tile data, with tile ids ordered along a Hilbert curve. Readers fetch only the byte ranges they need, so no tile server runs anywhere.

How big is a statewide PMTiles basemap?

For Oregon, cut from the Protomaps daily planet build with pmtiles extract, 231 MB through zoom 14 and 46 MB through zoom 12, measured on 30 August 2026. Protomaps notes that each extra zoom level roughly doubles the file, and Cloudflare caches objects up to 512 MB on its Free, Pro and Business plans.

Can a browser cluster 28,000 points?

Yes. MapLibre clusters inside the GeoJSON source: cluster true, a cluster radius, a max cluster zoom, and clusterProperties to aggregate a value such as the sum of prices, so a cluster disc can print an average without a second pass over the data. Circle and symbol layers do the drawing, never DOM markers.

Why does this build pin MapLibre 5 instead of 6?

MapLibre GL JS v6, published 22 July 2026, ships as ES modules only: the UMD bundle and the CSP build are no longer published, and WebGL2 became mandatory. Version 5.24.0 is the last line with a classic script build and a WebGL1 path, which is what this page loads.

Related

← All insights