High-performance PMTiles backend built with Express and TypeScript, designed for large-scale map traffic.
- Byte-range streaming for
.pmtilesfiles (206 Partial Contentsupport) - HTTP cache validation (
ETag,Last-Modified,304 Not Modified) - Multi-worker cluster mode for better CPU usage
- Structured JSON logging
- Request logging middleware with latency metrics
- Health and readiness endpoints for orchestration
- Prometheus-style metrics endpoint
- TypeScript modular architecture for maintainability
src/
app.ts # Express app wiring
index.ts # Entry point
cluster.ts # Primary/worker process orchestration
server.ts # HTTP server lifecycle + graceful shutdown
config/
env.ts # Environment config
middleware/
requestLogger.ts # Access log middleware
requestMetrics.ts # In-memory counters
routes/
systemRoutes.ts # /healthz, /metrics
tileRoutes.ts # /readyz, /tiles/map.pmtiles
services/
pmtilesStore.ts # PMTiles fd/stat/etag handling
tileResponder.ts # HEAD/GET + range + cache response logic
metrics.ts # Prometheus text rendering
utils/
logger.ts # Structured logger
types/
range.ts # Range parsing types
- Node.js 20+
- npm
- A PMTiles file located at
data/map.pmtiles
npm installnpm run devnpm run dev:tsxnpm run build
npm startnpm run build
npm run start:clusterGET /tiles/map.pmtiles- Streams full file or byte rangesHEAD /tiles/map.pmtiles- Metadata headers onlyGET /healthz- Liveness checkGET /readyz- Readiness check (ensures PMTiles file is readable)GET /metrics- Prometheus-style countersPOST /routing/solve- Backend route solving from stop points (snapped/fallback modes)
- Request type:
- Method:
GET - Body: none
- Headers:
- Optional
Range: bytes=<start>-<end> - Optional cache validators:
If-None-Match,If-Modified-Since
- Optional
- Method:
- Response type:
200 OK(full file) or206 Partial Content(range response)- Content-Type:
application/octet-stream - Binary body: PMTiles bytes
- Key headers:
Content-Length,Content-Range(for 206),Accept-Ranges,ETag,Last-Modified,Cache-Control 304 Not Modifiedwhen validators match400 Bad Requestfor invalid range header:
{"error":"Invalid Range header"}416 Range Not Satisfiablewith headerContent-Range: bytes */<fileSize>500 Internal Server Errorwhen file cannot be read:
{"error":"Cannot read PMTiles file: <message>"}- Request type:
- Method:
HEAD - Body: none
- Optional cache validator headers:
If-None-Match,If-Modified-Since
- Method:
- Response type:
200 OKwith headers only (no body)304 Not Modifiedwith headers only- Headers:
Content-Length,Accept-Ranges,ETag,Last-Modified,Cache-Control,Content-Type 500 Internal Server ErrorJSON on failure:
{"error":"Cannot read PMTiles file: <message>"}- Request type:
- Method:
GET - Body: none
- Method:
- Response type:
200 OKJSON:
{"status":"ok","worker":12345}- Request type:
- Method:
GET - Body: none
- Method:
- Response type:
200 OKJSON:
{"ready":true,"file":"/abs/path/to/data/map.pmtiles","size":123456789}503 Service UnavailableJSON:
{"ready":false,"error":"<message>"}- Request type:
- Method:
GET - Body: none
- Method:
- Response type:
200 OK- Content-Type:
text/plain - Body format: Prometheus exposition text, for example:
pmtiles_requests_total 12
pmtiles_requests_active 1
pmtiles_bytes_sent_total 1048576
pmtiles_range_requests_total 10
pmtiles_full_requests_total 2
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP server port |
HOST |
0.0.0.0 |
Bind host |
PMTILES_PATH |
./data/map.pmtiles |
Absolute/relative path to PMTiles file |
WORKERS |
1 |
Number of worker processes (0 = auto CPU count) |
CORS_ORIGIN |
* |
Allowed CORS origin |
CACHE_CONTROL |
public, max-age=3600, stale-while-revalidate=60 |
Cache header for map responses |
STAT_REFRESH_MS |
10000 |
Metadata refresh interval |
LOG_LEVEL |
info |
debug, info, warn, error |
Application and HTTP access logs are emitted as structured JSON (one line per event), which is ready for ingestion by log systems.
Example:
{"timestamp":"2026-05-28T16:00:00.000Z","level":"info","pid":12345,"message":"http_request","meta":{"method":"GET","path":"/tiles/map.pmtiles","statusCode":206,"durationMs":4.12}}You have multiple options.
- Download a
.pmtilesfile from a trusted provider. - Save/rename it to:
data/map.pmtiles
- Start server:
npm run devTypical flow:
- Download region extract (
.osm.pbf) from providers such as Geofabrik. - Convert to PMTiles using a tile generation tool (for example Planetiler or other PMTiles-capable pipeline).
- Place resulting file at
data/map.pmtiles.
Example (Planetiler-style workflow; tool must be installed separately):
# 1) Download OSM PBF (example)
curl -L -o data/region.osm.pbf "https://download.geofabrik.de/asia/sri-lanka-latest.osm.pbf"
# 2) Generate PMTiles (example command pattern; adjust for your tool/version)
planetiler --download=false --osm-path=data/region.osm.pbf --output=data/map.pmtilesNote: generation commands vary by tool and version. Confirm with your chosen generator's docs.
For real internet-scale traffic, app code is only one part. Use:
- CDN in front of this server (Cloudflare/Fastly/CloudFront)
- Load balancer + multiple instances
- Autoscaling + multi-zone deployment
- Monitoring/alerting using
/metrics
This project includes a ready monitoring stack:
- Prometheus scrape config:
monitoring/prometheus/prometheus.yml - Grafana provisioning:
monitoring/grafana/provisioning/... - Prebuilt dashboard:
monitoring/grafana/dashboards/pmtiles-overview.json - Compose file:
docker-compose.monitoring.yml
- Start backend first:
npm run build
npm run start:cluster- In another terminal, start monitoring:
npm run monitor:up- Prometheus: http://localhost:9090
- Grafana: http://localhost:3000
- Username:
admin - Password:
admin
- Username:
The PMTiles Backend Overview dashboard is auto-loaded.
npm run monitor:downnpm run monitor:logsPrometheus targets host.docker.internal:8080, so your Node server must run on host port 8080.
If your backend runs on a different port, update:
monitoring/prometheus/prometheus.ymltarget value
- Edit TS source in
src/ npm run devfor local iterationnpm run buildfor type-safe compile output todist/npm startto run compiled output
Unified suite location:
tests/perf/autocannon.mjs(quick benchmark)tests/perf/k6-load.js(real load profile)tests/perf/run-suite.mjs(single runner for both)
- Quick benchmark (Autocannon):
npm run perf:quick- Real load test (k6):
npm run perf:load- Run both in sequence:
npm run perf:all- Start backend first:
npm run build
npm run start:cluster- In another terminal, run perf tests (
perf:quick,perf:load, orperf:all).
PERF_BASE_URL(defaulthttp://localhost:8080)PERF_DURATION(autocannon seconds, default20)PERF_CONNECTIONS(autocannon concurrent connections, default200)PERF_PIPELINING(autocannon pipelining, default1)
Example:
PERF_BASE_URL=http://localhost:8080 PERF_DURATION=30 PERF_CONNECTIONS=400 npm run perf:quickk6 must be installed on your system and available in PATH.
- Ensure
data/map.pmtilesexists and is readable - Confirm
PMTILES_PATHif using custom location
- Client requested byte range outside file size
- Ensure the client supports PMTiles range reads correctly
- Set
CORS_ORIGINto your frontend origin (instead of*) for strict policies
ISC (from package.json)