DEV Community

Srdjan Popovic
Srdjan Popovic

Posted on

My Tile Cache Has No Invalidation, So I Set It to Zero

Here is a line from the config of a vector tile server that has been in production for months, serving 2.7 million features to a web GIS:

cache_size_mb: 0
Enter fullscreen mode Exit fullscreen mode

That's a cache, deliberately disabled. Underneath it, a comment I wrote for whoever touches this next — probably me, having forgotten:

MUST stay 0. Martin's in-memory tile cache has no invalidation hook for underlying data changes.

This post is about how I ended up there, because "turn off the cache" is the kind of decision that looks lazy until you've watched the alternative fail.

What the system is

Martin is a vector tile server written in Rust. Point it at PostGIS and it will discover your spatial tables and views and serve each one as an MVT endpoint. It is genuinely excellent, and none of what follows is a complaint about it.

In my case it sits in front of a road-inventory database: 1.8 million point features, 697,000 lines, 172,000 polygons, spread across roughly a hundred layers. Users don't just look at that data. They edit it — move a sign, redraw a kerb line, correct an attribute — and expect the map to show the change.

That last sentence is the whole problem.

The default that looks free

Martin's tile cache defaults to 512 MB, on. For most tile workloads that's an obvious win: your data is a static extract refreshed nightly, tiles are expensive to build, memory is cheap. Cache everything.

I left it on initially for exactly that reasoning. Then a user reported that a geometry they had edited still looked wrong on the map.

The natural suspects, in order: the browser cached the tile, nginx cached the tile, the edit never committed, or the edit landed in the wrong layer. I checked all four. The database had the new geometry. The layer view returned the new geometry. The API returned the new geometry. The map did not.

The experiment

The useful thing about a tile is that it's a file. You can fetch it and look at its size.

So: pick a feature, note the tile that contains it, fetch the tile, record the byte count. Edit the feature's geometry so the shape visibly changes. Fetch the same tile again.

before edit:  242 B
after edit:   242 B
30s later:    242 B
Enter fullscreen mode Exit fullscreen mode

Byte-identical. Not "similar size" — identical. Meanwhile the database and the layer view were both returning the new shape the entire time.

I waited. It stayed 242 B. I waited longer. Still 242 B. The tile was still 242 B when I restarted the container, at which point it immediately became the correct tile.

A cached tile is served unchanged for the lifetime of the process. There is no TTL to wait out and no hook that notices the underlying rows moved.

The setting that looks like it would help, and doesn't

There is a reload_interval in the config, and when you are staring at a stale tile it reads like the answer:

reload_interval: 5s
Enter fullscreen mode Exit fullscreen mode

It isn't. reload_interval re-discovers the catalog — which tables and views exist, what their geometry columns are, what SRID they carry. It's what makes a newly created layer show up as a tile source without a restart, which in a system where users create layers is genuinely valuable.

It does not touch cached tiles. The catalog and the cache are different things, and only one of them refreshes.

This is worth stating plainly because the two settings sit near each other in the config file and it is very easy to assume one covers the other. I assumed it for a while.

What "no invalidation" actually means

It's tempting to file this as a missing feature. It isn't, really — it's a hard problem wearing a simple name.

To invalidate correctly, the tile server would have to know that a row changed, work out which tiles at which zoom levels contained the old geometry and which contain the new one, and evict all of them. That means the tile server needs a change feed from Postgres, plus geometry-to-tile-index math for both the before and after state, at every zoom level.

That is a substantial amount of machinery for a server whose main job is to be a fast, boring translator between PostGIS and MVT. Most deployments don't need it, because most tile data doesn't change under the reader.

Mine does. So the cache is the wrong tool for my workload, and the right move is to not use it.

What it costs to turn off

This is the part I want to be honest about, because "I disabled the cache and everything was fine" would be a suspiciously tidy ending.

Every tile request now goes to Postgres. With cache_size_mb: 0, Martin builds each tile with ST_AsMVT on demand, on every request, through pgbouncer, against tables holding millions of rows.

That is survivable for a specific reason: the data is not read by the public. This is an internal tool with a bounded number of authenticated users working on a bounded number of projects. The requests-per-second ceiling is set by how fast a few dozen people can pan a map, not by the internet.

If this were a public basemap, the answer would be completely different — you'd cache aggressively at the edge and accept that edits take minutes to appear, or you'd pre-render tiles and rebuild on write.

So the real lesson isn't "disable your cache". It's that cache correctness is a property of your workload, not of your tile server, and the default is tuned for the workload where readers vastly outnumber writers.

What replaced it

I left something out above, and it changes the conclusion.

Turning off Martin's cache did not leave the system uncached. One layer up, nginx caches the same tiles:

location ~ ^/(.+)/(\d+)/(\d+)/(\d+)$ {
    proxy_cache       martin_tiles_cache;
    proxy_cache_key   $scheme$host$request_uri;
    proxy_cache_lock  on;
    proxy_cache_valid 200 302 5s;
}
Enter fullscreen mode Exit fullscreen mode

Five seconds.

That number looks almost pointless until you think about what a map client does. Panning a map fires dozens of tile requests, many of them repeats, within a second or two. proxy_cache_lock on collapses concurrent requests for the same tile into one upstream fetch. The five-second window absorbs exactly that burst and nothing more.

So the real difference between the two caches was never "cached vs uncached". It was:

Martin's internal cache nginx cache
TTL none — process lifetime 5s
Eviction memory pressure only expiry, and the cache dir is purgeable
Effect of an edit invisible until restart visible within 5s

A cache without a TTL is not a cache with a long TTL. It is a different thing. The first is a promise that data never changes; the second is a bet that it changes slower than the window. Only one of those was true here.

The lesson isn't "don't cache tiles that users edit". It's that the acceptable staleness window is a product decision — five seconds is fine, five minutes probably is too, and "until someone restarts a container" isn't a window at all.

The other number in that file

While I was in there, one more setting was wrong in the opposite direction:

worker_processes: 4
Enter fullscreen mode Exit fullscreen mode

It had been 18. Martin, like a lot of servers, picks a worker count from the host's visible CPUs — and inside a container, the host's CPU count is not your CPU allowance. Eighteen workers were contending for two cores' worth of scheduling.

More workers than cores does not add throughput once you're saturated; it adds context switching and queueing, and it shows up in tail latency rather than in the average. The p50 looks fine. The p99 is where users live.

Setting the worker count to the actual CPU allowance is one of those changes that produces no visible improvement in a benchmark and a real one in how the application feels.

What I'd tell someone setting this up

Ask what your staleness window is, and whether the cache can honour it. If users edit rows and expect to see the result, a cache with no TTL and no invalidation is not a performance optimisation — it's a correctness bug you configured on purpose. A cache with a short TTL, one layer out, gets you most of the benefit and bounds the damage.

Test invalidation by byte count. Fetch a tile, change the data, fetch again, compare sizes. It takes two minutes and gives you a fact instead of a belief. I spent longer than that theorising about nginx.

Read what your reload setting actually reloads. Catalog discovery and cache eviction are different, and adjacent config keys imply a relationship that isn't there.

Give containerised servers your CPU allowance, not the host's. Anything that auto-detects nproc inside a container is auto-detecting the wrong number.

And write the reason in the config file. That comment is the only thing standing between this setting and someone — me, in a year, looking at a cache set to zero and thinking it must be a mistake.

Top comments (0)