← Writing

25 Oct 2026 · 5 min read

Serving rasters on a small budget

Cloud Optimized GeoTIFFs, TiTiler, and knowing when not to cut a tile. What I learned making per-pixel maps fast on a one-vCPU API, with numbers.

A lot of my work has involved one shape of problem: a model produces a value for every 10 m pixel over thousands of small polygons, and people want to see it on a map: in a browser, on a phone, sometimes offline in a field with no signal. The API that serves it runs on modest hardware. Here’s what made that work, roughly in the order I learned it.

Store values, not pictures

The first version rendered a PNG per polygon per date, each one coloured against its own value range. A single season produced about 28,000 images, and the colours lied: the same green meant different things on neighbouring polygons, because each image had been normalised on its own.

We replaced them with one Cloud Optimized GeoTIFF (COG) per layer and period, holding the raw values in physical units, plus a small manifest describing each file. TiTiler serves tiles straight from those files, and the colour map is applied at request time against a single legend range for the whole project. The same value is now the same colour everywhere, and changing the legend no longer means regenerating anything.

Two kinds of raster, two sets of rules

Not every layer is a measurement. It helps to sort them into two families early, and let the family decide everything else:

continuous categorical
example a quantity per pixel a class per pixel
data type float32, physical units uint8 + colour map
nodata NaN a reserved value (255)
overview resampling average nearest, always
legend one range for the whole project, precomputed fixed classes

The rule people get wrong is the last row for categories: averaging class 1 and class 3 gives you class 2, which might be something completely different. Categorical rasters use nearest everywhere: in the warp, in the overviews, and in the client.

Also, compute the legend range when the data is written, not when it’s read. A list endpoint that worked out each layer’s extent on every request took 31.5 s on a 10,000-polygon test project. Precomputing the ranges at ingestion, and replacing a query per polygon with one spatial join, took it to 0.23 s.

Range requests are great, until you look at the tail

COGs are designed so a client can read just the bytes it needs over HTTP. GDAL does this through /vsicurl/, so a tile or a point lookup fetches a few small ranges, not the whole file. On a local disk, clipping one polygon’s pixels measured 141 ms at p50 and 208 ms at p90.

Reading the same COG from object storage told a different story:

COG location p50 p90 first call (cold)
worker’s local disk 141 ms 208 ms n/a
object storage, warm process 259 ms 909 ms 2,613 ms

The median looked fine in a demo. The p90 blew a 500 ms budget, and the cold first call was worse. The fix was boring: keep a copy of the project’s COG on the worker as a disposable cache (46 MB for a large project), derived and safe to delete, and go back to local-disk latency.

Contention showed up too. The polygon clipper alone took 649–987 ms on a heavy view, and about 2 s while TiTiler was reading the same file for map tiles. Batching requests over a 250 ms window removed most of it.

Pick the serving path by who’s asking

There is no single best way to serve pixels. The design we landed on uses three, chosen by client and zoom:

  • One polygon on a phone: clip directly from the COG. 17 ms, nothing stored, nothing to invalidate when the model is retrained.
  • Close zoom on the web: pixels as vectors from PostGIS through a vector tile server. 5–9 ms, filterable, and no reads from object storage, so no contention with the raster tiles.
  • Wide zoom: raster tiles from TiTiler.

Know when not to cut a tile

The phone app downloads a project’s map for offline use. It stops at zoom 15, but every z15 tile was cut live from the COG, about 4.5 s each. An offline download asks for hundreds at once, the one-vCPU API saturated, and the app got 504s.

The fix: cut once, then redirect. When a run finishes, a background worker cuts every z15 tile and stores it in object storage. The tile endpoint keeps the same URL, but for an exported project it now answers with a redirect:

HTTP/1.1 302 Found
Location: https://storage.example.com/…/15/15095/15194.mvt
Cache-Control: public, max-age=300

and the stored file is immutable:

HTTP/2 200
content-type: application/vnd.mapbox-vector-tile
content-encoding: gzip
cache-control: public, max-age=31536000, immutable

A few details made it work:

  • Store one zoom level. Map clients overzoom: with maxzoom: 15 set on the source (not the layer), z16–z22 are drawn from the z15 tile on the device, and a z15 tile is precise to about 0.3 m. Storing higher zooms would cost the same cut per level and approach one file per pixel.
  • Don’t store empty tiles. A missing object returns 404, which the map draws as “nothing here”, exactly like the empty 200 the live route sent.
  • Byte-for-byte equal. The stored tile is the same bytes the live route returns, checked on a hundred real tiles, so clients can’t tell the difference.
  • Retire on change. A new run retires the old tiles the moment its numbers are stored. Until the new export finishes, the endpoint falls back to cutting live, so a stale picture is never served.

On the largest project the slowest z15 request went from 10.5 s to 1.2 s, and the API stopped being the thing that falls over.

Refuse what you can’t serve

The last lesson is about limits. Some requests shouldn’t be answered with something approximate:

  • A grid of 10 m pixels over a whole country is about two billion pixels. The service says no instead of silently downsampling and returning a picture that looks right but isn’t.
  • When an upstream source rate-limits us, a circuit breaker trips and the response says throttled, not no data.
  • Expensive backfills sit behind a separate key.

Every one of these is a case where returning something would have looked better and been worse.

In short

  1. Store values, not pictures, and colour them at the edge.
  2. Let the type of raster (continuous or categorical) decide its encoding and resampling.
  3. Precompute anything a request would otherwise scan for.
  4. Measure p90 and cold starts on the real storage path, not just p50 on a laptop.
  5. Precompute the hot path once, then redirect to immutable files.
  6. Say no clearly when a request is beyond what you can serve honestly.