Making maps work offline with PMTiles
Two years ago I wrote about discovering Protomaps and the PMTiles format. At the time, PMTiles v2 was the current spec and I was still figuring out whether this approach could work for a real product. Since then, PMTiles v3 shipped, the tooling matured significantly, and I have been running it in production for Landbound.
This is the follow-up I promised. What changed, what I learned, and how I got offline maps working.
What PMTiles v3 fixed
The v2 spec worked, but it had rough edges. The initial directory fetch was around 512KB, which was noticeable on mobile connections. Tile entries were stored as flat z/x/y lookups with no particular ordering, which made the directory bloated and hard to compress.
v3 redesigned the internals. The big changes:
- Hilbert tile IDs. Instead of storing z/x/y coordinates separately, v3 maps each tile to a single ID on a Hilbert curve. Spatially close tiles get numerically close IDs. This makes delta encoding and run-length encoding of the directory far more efficient.
- Compressed directories. The directory structure now compresses to roughly 10% of the v2 size. That initial fetch that used to be 512KB drops to well under 100KB for most regional extracts.
- Clustered layout. Tile data is laid out in tile ID order, which means sequential reads for spatially adjacent tiles. This matters for both caching and range request performance.
- Fewer range requests. With a more compact directory and leaf directories for deep zoom levels, the client typically needs one or two HTTP requests to resolve any tile location instead of potentially more with v2.
The practical result: faster initial load, smaller overhead per tile fetch, and better behavior on flaky connections. Exactly what you need for an outdoor trip planning app where users might be on spotty cell service.
Generating tiles with Planetiler
In my original post I showed a workflow using Tilemaker and the go-pmtiles CLI to convert MBTiles to PMTiles. That still works, but Planetiler has become the better tool for this job.
Planetiler is a Java tool by Michael Barry that generates vector tiles from OpenStreetMap data. It is fast, memory-efficient, and outputs PMTiles directly. No intermediate MBTiles conversion step.
Grab a regional extract from Geofabrik and point Planetiler at it:
wget https://download.geofabrik.de/europe/germany-latest.osm.pbf
java -Xmx4g -jar planetiler.jar \
--osm-path=germany-latest.osm.pbf \
--output=germany.pmtiles \
--nodemap-type=array \
--downloadThe --download flag tells Planetiler to fetch Natural Earth and lake centerline data it needs for lower zoom levels. The --nodemap-type=array option uses more memory but is faster for large extracts.
For Germany, this takes about 20 minutes on a machine with 4 cores and 8GB RAM. The output is around 3.5GB for a full vector tileset with all zoom levels through z14. If you only need up to z12, it drops closer to 1.5GB. Zoom level limits are one of the most effective levers for controlling file size.
You can also run Planetiler via Docker if you do not want to deal with Java locally:
docker run --rm -v "$(pwd)/data":/data ghcr.io/onthegomap/planetiler:latest \
--osm-path=/data/germany-latest.osm.pbf \
--output=/data/germany.pmtilesHosting on Cloudflare R2
PMTiles needs a host that supports HTTP range requests. Any object storage or static file server works, but Cloudflare R2 is the obvious choice for this use case.
The pricing is straightforward: $0.015 per GB per month for storage, zero egress fees. That last part is the important one. Map tiles generate a lot of bandwidth. A single user panning and zooming around a map for a few minutes can fetch hundreds of tiles. With S3, that egress adds up fast. With R2, you pay nothing for it.
Upload is simple with the S3-compatible API:
aws s3 cp germany.pmtiles s3://landbound-tiles/germany.pmtiles \
--endpoint-url https://ACCOUNT_ID.r2.cloudflarestorage.comMake the bucket publicly accessible, and you have a tile server. No infrastructure to manage, no processes to monitor, no scaling to think about.
For a 3.5GB Germany tileset, the monthly storage cost is about $0.05. That is not a typo.
MapLibre integration
I covered the MapLibre ecosystem in my comparison of mapping libraries. For PMTiles integration, the pmtiles npm package provides a Protocol class that hooks into MapLibre's custom protocol system.
import maplibregl from "maplibre-gl";
import { Protocol } from "pmtiles";
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
openmaptiles: {
type: "vector",
url: "pmtiles://https://tiles.landbound.app/germany.pmtiles",
},
},
layers: [
{
id: "water",
type: "fill",
source: "openmaptiles",
"source-layer": "water",
paint: { "fill-color": "#a0c4ff" },
},
{
id: "roads",
type: "line",
source: "openmaptiles",
"source-layer": "transportation",
paint: { "line-color": "#666", "line-width": 1.5 },
},
],
},
});The pmtiles:// prefix tells the Protocol to intercept the request, read the PMTiles directory to find the byte offset for the requested tile, and make a range request for just those bytes. MapLibre does not know or care that the tiles are coming from a single file. It just gets tiles back.
In a React app, register the protocol once at the top level:
import { useEffect } from "react";
import maplibregl from "maplibre-gl";
import { Protocol } from "pmtiles";
export function useMapProtocol() {
useEffect(() => {
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
return () => maplibregl.removeProtocol("pmtiles");
}, []);
}How the architecture differs
The difference between a traditional tile server and the PMTiles approach is worth visualizing:
graph LR
subgraph Traditional
C1[Client] --> TS[Tile Server]
TS --> DB[(PostGIS)]
TS --> Cache[Tile Cache]
end
subgraph PMTiles
C2[Client] --> PM[pmtiles Protocol]
PM -->|Range Request| R2[Static File on R2]
end
Traditional tile serving involves a server process that reads from a database, renders tiles on the fly or from cache, and serves them to clients. PMTiles eliminates all of that. The client does the work of resolving tile coordinates to byte ranges, and the server is just dumb file storage.
This means there is nothing to crash, nothing to scale, nothing to patch. The only moving part is the CDN.
Going offline with Service Workers
This is where PMTiles gets interesting for an app like Landbound. Outdoor trip planning means users need maps in areas with no cell service. The PMTiles architecture lends itself well to offline support because the tile fetching is already happening through a protocol handler in the browser.
The strategy: use a Service Worker to intercept range requests and cache the responses in the Cache API.
The concept works like this. When the user is online and browsing the map, the Service Worker intercepts each range request going to the PMTiles file. It passes the request through to the network and stores the response in a cache. The next time that exact byte range is requested, it comes from the cache instead of the network.
For proactive caching, you can pre-fetch tiles for a specific bounding box before the user goes offline. Calculate which tile coordinates fall within the area, resolve them to byte ranges using the PMTiles directory, and fetch them into the cache. The user then has those tiles available without a network connection.
There are limits. The Cache API stores complete responses keyed by request URL including the Range header. For a regional trip covering a few hundred square kilometers at zoom levels 0 through 14, you are looking at a few thousand tiles. That is manageable. Pre-caching all of Germany at all zoom levels is not realistic on a mobile device.
Managing file sizes
A 3.5GB file for all of Germany at z14 is fine for server-side hosting but too large to pre-cache on a device. Here is how I keep things practical:
- Regional extracts. Geofabrik offers extracts for individual German states. Bavaria is around 600MB. Much more manageable.
- Zoom level limits. Most outdoor use cases do not need zoom levels beyond 14. Cutting from z14 to z12 can halve the file size.
- Layer filtering. Not every layer matters for every use case. Dropping POI labels, building footprints, and land use polygons at high zoom levels reduces size significantly.
- Multiple files. Nothing stops you from using different PMTiles files for different regions. Landbound loads the appropriate regional file based on where the user is planning a trip.
The pmtiles CLI can extract a subset of an archive by bounding box, which is useful for creating focused regional tiles from a larger source:
pmtiles extract germany.pmtiles bavaria.pmtiles \
--bbox=8.9,47.2,13.9,50.6Two years later
When I wrote the first Protomaps post, I was cautiously optimistic. The idea was compelling but the tooling felt early. Now, two years in, the ecosystem has caught up. Planetiler makes tile generation fast and painless. PMTiles v3 solved the performance issues that v2 had. R2 makes hosting essentially free. And the MapLibre integration is clean enough that swapping between a traditional tile server and PMTiles is a style configuration change.
For Landbound, the offline capability is the real win. Trip planning apps are useless if the map disappears when you drive into a valley. PMTiles makes it practical to cache exactly the tiles a user needs for their trip without building a complex tile server infrastructure.
Sources
Related posts
Why I built Omnibase: a universal database MCP server
I got tired of copy-pasting query results between DataGrip and AI agents. So I built an MCP server that gives AI agents secure, direct access to any database.
Delta libraries: how diffing works and which library to use
What delta libraries do, how diff algorithms work under the hood, and a practical comparison of the most popular options in the JavaScript ecosystem.
Offline-first apps: harder than it sounds
Building apps that work without internet is one of those things that seems straightforward until you actually try it. Here is what makes it hard and how to approach it.
Enjoying the blog? Subscribe via RSS to get new posts in your reader.
Subscribe via RSS