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.

Request flow for one viewport, from renderer to archive and back A renderer issues eighteen concurrent tile requests during a pan. A semaphore in front of the handler admits four at a time so the reader never has more than four outstanding flash reads. Each admitted request checks an in-process hot cache, falls through to the archive on a miss, and returns the compressed bytes with a content-encoding header so the browser decompresses rather than the gateway. Requests beyond the semaphore wait in the accept queue, which bounds memory instead of letting eighteen buffers exist at once. Eighteen requests arrive at once; four are allowed to touch flash renderer 18 tiles in one pan HTTP keep-alive semaphore · 4 the rest wait in the accept queue hot cache hit → no flash read ≈70% during a pan archive read one pread bytes unchanged 200 + gzip header Without the semaphore, eighteen concurrent handlers each hold a tile buffer and eighteen flash reads queue at the block layer — the pan finishes no sooner and the ingestion thread waits behind them for its own disk access.
Admission control is the whole design. The archive is fast; what needs bounding is how much of the device a single pan is allowed to occupy.

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.0 is 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: gzip requires 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 fsync will 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.
Frame time during a pan, with and without bounded concurrency Two distributions of per-tile response time during an eighteen-tile pan. Unbounded, the median is 41 milliseconds and the 95th percentile is 180 because eighteen reads queue at the block layer and the ingestion thread's own I/O is interleaved among them. Bounded at four, the median rises slightly to 46 milliseconds but the 95th percentile falls to 71, and the ingestion thread's worst-case disk wait drops from 160 milliseconds to 22. Bounding concurrency trades a slower median for a much shorter tail unboundedbounded at 4 median 41 ms p95 180 ms median 46 ms p95 71 ms ingestion thread's worst disk wait 160 ms unbounded → 22 ms bounded Why the median moves at all the 5th to 18th tiles now wait for a slot before their read starts
The number that matters on a gateway is not the map's median frame time — it is how long the acquisition path waits for the disk while the map is being panned.

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.

Reading the three tile-server counters together A decision table over three signals. High hit ratio with low latency and steady request volume is normal. A collapsed hit ratio with normal latency points at a jumping viewport, which is a positioning fault. A normal hit ratio with rising latency points at flash wear. Request volume falling to zero while the other two look healthy points at the renderer, not at the tile path. Three counters, four conclusions hit ≈70% · latency <1 ms · volume steady → healthy; nothing to do hit ≈0% · latency normal → the viewport is jumping: look at the position source, not the archive hit normal · latency climbing into tens of ms → the card is wearing out; plan a swap volume → 0, everything else fine → the renderer stopped asking; the tile path is not the fault
Each row is a different repair. Without all three counters, every one of them presents to the operator identically: a blank map.

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.