Serving vector tiles from a read-only SQLite cache
A map component in a field application expects to fetch tiles over HTTP, and on a disconnected gateway there is nothing at the other end of that fetch unless the device provides it. This page builds the small loopback tile server that closes that gap: a single-process HTTP surface over the read-only archive described in offline tile and basemap storage, sized for a 512 MB ARM gateway inside the Core Edge GIS Fundamentals envelope.
The design constraint that shapes everything here is that this server must never be the reason the device misses telemetry. It shares a core with the ingestion pipeline, it is driven by a renderer that will happily request twenty tiles at once during a pan, and it has no admission control of its own unless someone builds one.
Why a local HTTP surface at all
Handing the renderer a file path instead of a URL is tempting and usually wrong. Map libraries expect tile URLs, cache by URL, and handle 404s as “no data here” rather than as an error — behaviour that has to be reimplemented if tiles arrive another way. A loopback server preserves all of it, costs a few hundred kilobytes of resident memory, and keeps the archive access in one place where it can be instrumented.
The alternative worth considering is embedding the tile bytes directly into the rendering process through a custom protocol handler, which removes the socket entirely. That is measurably faster and considerably less debuggable, and it only pays off on devices where the renderer and the tile store are the same process. For the common case — a browser-based operator interface talking to a Python service — the loopback server wins on every axis except raw latency.
The complete server
# tile_server.py — loopback vector-tile server over a read-only archive.
# asyncio, single process. The archive read is synchronous and short (one
# pread), so it runs in a small thread executor rather than blocking the loop.
# GC note: tile bytes are handed straight to the transport; no per-request
# copies, so the collector sees almost no garbage under load.
import asyncio
import logging
from http import HTTPStatus
from tile_source import TileSource # MBTiles or PMTiles, chosen at start-up
LOG = logging.getLogger("tiles")
MAX_INFLIGHT = 4 # concurrent archive reads
READ_TIMEOUT_S = 2.0
_EMPTY_PBF = b"" # a valid, empty vector tile
class TileServer:
def __init__(self, archive: str, host: str = "127.0.0.1", port: int = 8081):
self.src = TileSource(archive)
self.host, self.port = host, port
self._gate = asyncio.Semaphore(MAX_INFLIGHT)
self._pool = None
async def start(self):
loop = asyncio.get_running_loop()
# One extra thread beyond MAX_INFLIGHT so a slow read cannot deadlock
# the pool while another request is being admitted.
self._pool = __import__("concurrent.futures").futures.ThreadPoolExecutor(
max_workers=MAX_INFLIGHT + 1, thread_name_prefix="tileio")
server = await asyncio.start_server(self._handle, self.host, self.port)
LOG.info("tiles on http://%s:%d (%s)", self.host, self.port, self.src.kind)
async with server:
await server.serve_forever()
async def _handle(self, reader, writer):
try:
line = await asyncio.wait_for(reader.readline(), timeout=5.0)
if not line:
return
parts = line.decode("latin-1").split()
if len(parts) < 2 or parts[0] != "GET":
return await self._respond(writer, HTTPStatus.METHOD_NOT_ALLOWED)
# Drain headers; we honour none of them except by ignoring them.
while True:
h = await asyncio.wait_for(reader.readline(), timeout=5.0)
if h in (b"\r\n", b"\n", b""):
break
zxy = _parse_path(parts[1])
if zxy is None:
return await self._respond(writer, HTTPStatus.NOT_FOUND)
blob = await self._read_tile(*zxy)
if blob is None:
# 204 rather than 404: map libraries treat it as "no data here"
# without logging an error for every empty tile at the edges.
return await self._respond(writer, HTTPStatus.NO_CONTENT, _EMPTY_PBF)
await self._respond(writer, HTTPStatus.OK, blob, gzip_encoded=True)
except (asyncio.TimeoutError, ConnectionResetError):
pass
finally:
writer.close()
async def _read_tile(self, z: int, x: int, y: int) -> bytes | None:
loop = asyncio.get_running_loop()
async with self._gate:
try:
return await asyncio.wait_for(
loop.run_in_executor(self._pool, self.src.get, z, x, y),
timeout=READ_TIMEOUT_S,
)
except asyncio.TimeoutError:
LOG.warning("archive read timed out z=%d x=%d y=%d", z, x, y)
return None
async def _respond(self, writer, status, body: bytes = b"", gzip_encoded=False):
head = [
f"HTTP/1.1 {status.value} {status.phrase}",
f"Content-Length: {len(body)}",
"Content-Type: application/vnd.mapbox-vector-tile",
"Cache-Control: public, max-age=604800, immutable",
"Access-Control-Allow-Origin: *",
]
if gzip_encoded:
head.append("Content-Encoding: gzip")
writer.write(("\r\n".join(head) + "\r\n\r\n").encode("latin-1"))
if body:
writer.write(body)
await writer.drain()
def _parse_path(path: str):
"""/tiles/{z}/{x}/{y}.pbf → (z, x, y), or None."""
if not path.startswith("/tiles/"):
return None
try:
z_s, x_s, y_s = path[len("/tiles/"):].split("/", 2)
y_s = y_s.split(".", 1)[0]
z, x, y = int(z_s), int(x_s), int(y_s)
except ValueError:
return None
if not (0 <= z <= 22) or not (0 <= x < (1 << z)) or not (0 <= y < (1 << z)):
return None
return z, x, y
Four decisions in that code are the ones worth defending. The semaphore is sized at four rather than at the core count because the limit being protected is the block device, not the CPU. The Cache-Control: immutable header means the renderer never revalidates a tile it already has, which removes most of the request volume during normal operation — tiles in a read-only archive genuinely never change, so the strongest possible caching directive is also the correct one. The 204 response for a missing tile keeps the browser console clean, which matters because a field engineer looking at that console is usually chasing something else. And the bounds check in _parse_path rejects nonsense coordinates before they reach the archive, because a renderer with a broken transform can otherwise generate an unbounded stream of misses.
Constraint validation
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| RAM | Concurrent handlers each holding a tile buffer multiply peak memory | Semaphore caps in-flight reads at four; bytes are written straight to the transport with no intermediate copy |
| CPU | Decompressing tiles on the gateway would burn cycles per request | Tiles are served compressed with Content-Encoding: gzip; the client decompresses |
| Latency | A slow flash read stalls the frame | 2 s read timeout returns 204 rather than hanging the renderer indefinitely |
| Flash contention | Tile reads compete with the spool’s writes | Bounded concurrency keeps the block queue short enough for the writer to interleave |
| Power | A busy loop or eager prefetch keeps the SoC awake | Purely reactive: no prefetch, no background scan, zero cost when the viewport is idle |
Gotchas and edge cases
- Bind to loopback, always. A tile server on
0.0.0.0is an unauthenticated read interface to whatever the archive contains, reachable from any network the device joins. If a companion device on a local network needs tiles, put them behind the same authenticated path as everything else rather than exposing this directly. Content-Encoding: gziprequires the bytes to actually be gzipped. MBTiles and PMTiles usually store vector tiles pre-compressed, but a locally generated archive may not. Read the archive metadata at start-up and set the header from it rather than assuming — a mismatch produces an empty map with no error anywhere.- Keep-alive matters more than it looks. A renderer opening a fresh connection per tile turns 18 tiles into 18 TCP handshakes; on loopback that is cheap but not free, and the accept queue becomes the bottleneck long before the archive does.
- Do not serve from the same thread that writes the spool. They are both I/O against the same device, and the spool’s
fsyncwill block the tile read behind it. Separate executors keep a sync flush from freezing the map. - 204 is not universally understood. Some map libraries expect 404 for a missing tile and log an error for 204, or vice versa. Check which one the renderer in your stack prefers and make it configurable; this is a two-line difference that avoids a console full of noise.
Field diagnostics
Expose three counters through the same registry the rest of the device uses, described in monitoring and observability: requests served, cache hit ratio, and archive read latency as a histogram. Together they answer every question this component generates. A collapsing hit ratio with normal latency means the viewport is jumping — a positioning problem. Normal hit ratio with rising latency means the flash is degrading. Falling request volume with everything else healthy means the renderer stopped asking, which is a UI fault rather than a tile fault.
Add one console command that fetches a known tile and reports its size and read time. A technician can then distinguish “the map is blank because the archive is missing” from “the map is blank because the renderer is not running” in a single line of output, without a browser.
Where this server does not belong
Two deployments should not use a loopback server at all, and recognising them early saves a rewrite.
The first is a native application that owns both the renderer and the tile store in one process. There the HTTP layer is pure overhead: a custom protocol handler or a direct byte-source callback removes the socket, the parsing and the concurrency question in one step, and the archive reader is the same either way.
The second is a device with more than one consumer of the same tiles on a shared network — a gateway serving a tablet and a vehicle head unit, for example. That looks like a case for a server and is actually a case for authentication, rate limiting and a cache policy per consumer, none of which belong in a two-hundred-line loopback handler. Put a real server in front of the same archive reader instead, and keep this component for the single-consumer case it was designed for.
Everything in between — one renderer, one process boundary, loopback only — is exactly what this fits, and it is the majority of field deployments.
Related
- Offline Tile & Basemap Storage — the archive, its coverage policy and the hot cache this server sits on.
- PMTiles vs MBTiles for read-only edge basemaps — the container choice behind
TileSource. - Pruning tile caches under a flash write budget — the writable half of the store and its wear budget.