If you have built an application with an interactive map, you know what you are in for. You pick a tile provider such as Google or Mapbox, or a self-hosted stack like GeoServer or Martin, and your frontend requests thousands of small image or vector tiles as the user interacts with the map. A server processes each request: it reads from a database, renders the tile, maybe caches it, and sends it back.
That server is infrastructure: it needs to be deployed, monitored, and scaled. For a company running multiple dashboards with maps (field service, fleet management, sales territory views), the tile server multiplies. Each deployment adds operational overhead and, with a commercial provider, per-request costs.
So I asked myself whether a different approach was possible. I found a format called PMTiles that removes the tile server entirely: the tiles come from a single static file, and a small client library fetches exactly the bytes of the tiles that the browser-side renderer (here, MapLibre GL JS, an open-source WebGL map library) asks for. A server still hands out bytes, but it can be any static file server: nothing map-specific runs on it.
This article explains how that works: the format itself, the HTTP mechanism that makes it possible, the toolchain that generates the files, the library that draws them, and the Spring Boot integration. It closes with how to overlay your own data on the basemap and what the stack costs.
What does a tile server do?
In a traditional setup, vector tiles live in a database or in an MBTiles file (which is just SQLite with a specific schema). A tile server sits between that data and the browser:
Browser → GET /tiles/6/34/22.pbf → Tile Server → Database/MBTiles ← 200 OK ← ←
The server’s job is straightforward: receive a request with z/x/y coordinates, look up the corresponding tile, and return it. Notice that, with pre-built tiles, the server only locates the tile. A browser can fetch a tile directly from a file, without a server in the middle doing the lookup, provided the file is structured so that the client knows where each tile lives inside it.
PMTiles: a file that replaces the server
PMTiles, created by Brandon Liu as part of the Protomaps project, is that kind of file. It is a single-file archive format (currently at specification version 3) that packs an entire tile pyramid into one binary blob with an internal index.
The structure is:
- A fixed-length header at byte offset 0, containing metadata and pointers.
- A directory: a compressed, hierarchical index that maps each tile coordinate (z/x/y, encoded as a single TileID along a Hilbert curve) to a byte offset and length inside the file.
- The tile data: in our case Mapbox Vector Tiles (MVT), usually compressed, packed sequentially.
The header and root directories sit within the first 16 KiB of the file, so the client can read them with a single request at the start. Once the directory is cached, the client knows the exact byte range of each tile it needs. At that point, fetching a tile is a single HTTP range request, with no server-side logic needed.
Why does all this hold up even for a file as large as the whole planet (about 120 GB)? If the file is small, the whole directory fits in the initial block: one request at startup, then one request per tile. If the file is large, the full directory would be too heavy to download, so it is split: the root directory, still in the first 16 KiB, points to many leaf directories, each describing a portion of the map. The client downloads only those for the area it is viewing and keeps them in memory.
The specification discourages more than one level of leaf directories. It follows that, whatever the size of the file, two requests are normally enough to reach a tile: the leaf directory for that area, then the tile. When you pan, most of the next tiles are in the leaf directory already downloaded, because tiles are numbered along a Hilbert curve: a path that crosses the grid of each zoom level always moving from one cell to an adjacent one. Tiles that are close on the map therefore get close numbers and tend to end up in the same leaf directory.
On the server side, nothing changes: reading 4 KiB from a 120 GB file costs the same as reading it from a 2 MB file. The cost of a session depends on how much of the map you look at, not on how big the file is.
How does a range request serve a tile?
HTTP range requests are defined in RFC 7233 (now folded into RFC 9110). The client sends a Range
header specifying which bytes it wants, and the server, which can be a plain static file server, an object storage bucket, or a CDN, returns only those bytes with a 206 Partial Content
status.
GET /data/planet.pmtiles HTTP/1.1 Range: bytes=48239616-48243711 HTTP/1.1 206 Partial Content Content-Range: bytes 48239616-48243711/120000000000 Content-Length: 4096 [tile data]
This is the same mechanism that lets a player jump to a point in a video, and a download manager resume an interrupted transfer. PMTiles repurposes it for map tiles: each tile is a byte range inside the archive, and the browser fetches exactly the bytes it needs.
The PMTiles JavaScript library does all of this on its own. It registers a custom protocol with MapLibre, intercepts tile requests, translates z/x/y coordinates into byte ranges using the in-memory directory, and issues the range requests. MapLibre receives the tiles as it would from any other source.
import { Protocol, PMTiles } from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
// Register the PMTiles file: a local path or a remote URL
const pmtiles = new PMTiles("/data/planet_z6.pmtiles");
protocol.add(pmtiles);
After this setup, any MapLibre source with a URL prefixed pmtiles://
is served from the archive.
From OpenStreetMap to a PMTiles file
The quickest way to get an archive is not to build one. Protomaps publishes daily planet builds, free, generated with Planetiler from the latest OpenStreetMap data. The full file is roughly 120 GB (zoom levels 0 to 15), but you rarely need all of it: pmtiles extract
cuts out a region, and its --maxzoom
option drops the levels of detail you do not need, without regenerating tiles from scratch. It can read the build straight from its URL, fetching only the bytes of that region with the same range requests described above. This is how the demo gets its file:
# Extract Italy, zoom 0–6, from a daily planet build
pmtiles extract https://build.protomaps.com/YYYYMMDD.pmtiles italy.pmtiles --bbox=6.63,36.62,18.52,47.09 --maxzoom=6
If you need to generate the archive yourself, there are three main tools in the pipeline, each suited to a different scenario.
Planetiler is a Java tool that generates vector tilesets directly from OpenStreetMap data. It is designed for planet-scale builds: the project’s own planet benchmark reports a little over three hours for the whole OpenStreetMap planet on a machine with 16 vCPUs and 128 GB of RAM.
# Generate PMTiles for Monaco from OpenStreetMap
java -Xmx1g -jar planetiler.jar --download --area=monaco --output=monaco.pmtiles
Tippecanoe, maintained by Felt, generates vector tiles from GeoJSON input. Since version 2.17, it writes PMTiles directly. This is the tool to use when you have your own data (sensor locations, store networks, infrastructure assets) that you want to package as tiles:
# Convert a GeoJSON file to PMTiles
tippecanoe -o stations.pmtiles -z12 -Z4 --drop-densest-as-needed stations.geojson
The pmtiles CLI, the go-pmtiles binary, provides the extract
command shown above, and also converts MBTiles archives to PMTiles:
# Convert from MBTiles
pmtiles convert planet.mbtiles planet.pmtiles
From Mapbox to MapLibre: why the fork matters
Mapbox GL JS, Mapbox’s library for drawing maps with WebGL, had been BSD-licensed since its inception. In December 2020, version 2.0 came under a proprietary license that allowed its use only with an active Mapbox subscription, with a cost tied to map loads.
Multiple independent forks appeared within days. To avoid fragmentation, these efforts converged into a single project: MapLibre GL JS, forked from the last open-source version (Mapbox GL JS 1.13) and licensed under BSD-3-Clause. People from MapTiler, Elastic, Microsoft, and other companies took part in the project.
MapLibre has since diverged from the Mapbox codebase. It has its own release cadence, dropped WebGL1 in version 6, and its contributors are backed by companies such as AWS, Meta, and Microsoft. It renders vector tiles with WebGL, styles them with data-driven expressions, and has a plugin ecosystem.
In practice, MapLibre is free to use, requires no access token, and works with any tile source, including PMTiles.
Integration with Spring Boot
Integrating PMTiles with Spring Boot needs no special configuration and no extra dependency, Geographic Information System or tile-serving: Spring Boot already knows how to serve static files.
I placed the PMTiles file in src/main/resources/static/data/
:
src/main/resources/static/ ├── data/ │ └── planet_z6.pmtiles ← the tile archive (an Italy extract, zoom 0–6) ├── index.html └── app.js
Spring Boot serves everything under static/
at the root URL by default. The PMTiles file (despite its name, only the Italy extract) is accessible at /data/planet_z6.pmtiles
. When the browser issues a range request, Spring MVC’s ResourceHttpRequestHandler
(the component behind static resources) answers it with 206 Partial Content
, because it ships with a ResourceRegionHttpMessageConverter
enabled out of the box. This works for a small extract such as the demo’s. Inside a jar, the file is a deflated entry, so reaching a byte offset means decompressing everything before it, on every tile request; a regional or planet-sized archive belongs outside the jar, on a static file server or object storage (see the costs section).
The frontend sets up the map in a single file:
// app.js: complete map setup
import * as maplibregl from "https://cdn.jsdelivr.net/npm/[email protected]/dist/maplibre-gl.mjs";
import { Protocol, PMTiles } from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";
import { layers, namedFlavor } from "https://cdn.jsdelivr.net/npm/@protomaps/[email protected]/+esm";
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile); (1)
const pmtiles = new PMTiles("/data/planet_z6.pmtiles");
protocol.add(pmtiles);
const map = new maplibregl.Map({ (2)
container: "map",
center: [12.4964, 41.9028], // Rome
zoom: 5,
style: {
version: 8,
glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/light",
sources: {
protomaps: {
type: "vector",
url: "pmtiles:///data/planet_z6.pmtiles", (3)
attribution: '<a href="https://protomaps.com">Protomaps</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}
},
layers: layers("protomaps", namedFlavor("light"), { lang: "en" })
}
});
1 | Register the pmtiles:// protocol with MapLibre, then add the archive it serves |
2 | Create the map with the Protomaps cartographic styles |
3 | The pmtiles:// scheme followed by the absolute path /data/planet_z6.pmtiles : the third slash starts the path; it is not a typo |
The layers()
function from @protomaps/basemaps
generates a full cartographic style (roads, water bodies, labels, land use) from the vector tiles. The namedFlavor()
function selects a theme: light
, dark
, grayscale
, white
, or black
.
The attribution
line is the easiest one to forget, but it is a legal requirement. The basemap is built from OpenStreetMap data, released under the Open Database License (ODbL). The tools are free to use, but a map drawn from that data must credit OpenStreetMap where users can see it: the OpenStreetMap Foundation guidelines suggest a corner of the map, with a link to the copyright page. MapLibre’s attribution control, enabled by default, displays the string on the map.
Adding your own data
A basemap alone is rarely enough. Most applications need to overlay their own data: device locations, service areas, and the like. MapLibre handles this through additional sources and layers. A typical case is a set of Internet of Things devices, overlaid on the basemap as a GeoJSON layer:
// Additional source in the style definition
sources: {
protomaps: { /* ... basemap tiles ... */ },
devices: {
type: "geojson",
data: { type: "FeatureCollection", features: [] } // loaded dynamically
}
}
// Layer with data-driven styling
const devicesLayer = {
id: "devices-circles",
type: "circle",
source: "devices",
paint: {
"circle-color": [
"match", ["get", "status"],
"online", "#16a34a", // green
"offline", "#dc2626", // red
"#2563eb" // default blue
],
"circle-radius": ["interpolate", ["linear"], ["zoom"], 3, 3, 12, 7]
}
};
// Basemap layers first, devices last: drawn on top
layers: [...layers("protomaps", namedFlavor("light"), { lang: "en" }), devicesLayer]
The data comes from a Spring Boot REST endpoint (/api/devices?bbox=...
) that returns a GeoJSON FeatureCollection filtered by the current viewport. On every moveend
event, the frontend fetches the relevant subset and updates the source. The basemap tiles and the device data coexist in the same map without interfering: MapLibre composites them in render order.
PMTiles handles the basemap; your application data flows through a separate, conventional REST API. The basemap is a static artifact that changes only when you decide to update it. The application data is dynamic, served by your business logic, and changes with every user interaction. Keeping them in separate sources makes both easier to test and deploy. If your own dataset is large and static, tile it with Tippecanoe and serve it as a second PMTiles source; for small or changing data, a GeoJSON source fed by a REST endpoint is simpler.
What this stack costs
All components are free; only the license varies.
Component | Role | License |
---|---|---|
MapLibre GL JS |
Vector tile rendering (WebGL) |
BSD-3-Clause |
PMTiles |
Single-file tile archive format |
BSD-3-Clause / CC0 (spec) |
Protomaps Basemaps |
Cartographic styles |
BSD-3-Clause (code), CC0 (style) |
Basemap data (planet builds) |
OpenStreetMap-derived tiles |
ODbL, attribution required |
Planetiler |
Generate PMTiles from OpenStreetMap |
Apache-2.0 |
Tippecanoe |
Generate PMTiles from GeoJSON |
BSD-2-Clause |
pmtiles CLI |
Convert + extract archives |
BSD-3-Clause |
Spring Boot |
Static file server + REST API |
Apache-2.0 |
With PMTiles, what you pay for is infrastructure (storage, bandwidth, and, on object storage, requests), not a map provider on every load. What you need to check depends on where the file lives:
- Same origin as the application: nothing to configure.
- Another origin: CORS must allow the
Range
andIf-Match
headers and exposeETag
. The client compares ETags to notice that the file was replaced. - Object storage: every tile is a range request, billed as a GET, so request charges grow with traffic.
- Behind a CDN: edge caching cuts latency and origin traffic, but providers handle range requests differently: check how yours behaves before putting a planet-sized archive behind it.
In every case, the OpenStreetMap attribution described above still applies.
Conclusion
"An interactive map requires a tile server" is too broad a statement. A more precise version could be: you need a tile server if your tiles change frequently, if you need server-side rendering of raster data, or if per-request charges on object storage or a CDN grow past the cost of running a server yourself; for static or infrequently updated basemaps, a single file and HTTP range requests do the job.
A PMTiles file is built once, versioned like any build artifact, and copied wherever it is needed, including an edge gateway or a site with no internet connection (in that case the JavaScript libraries, fonts and sprites must be served locally too). When I integrated it into a Spring Boot application, the tile infrastructure was a single static file in the resources
folder plus a few lines of configuration in the frontend; the rest of my time went into the parts that matter: the domain data and the queries.
The complete source code for this post can be found on GitHub.