Estimating Tile Seed Time and Storage Before You Run It

Sample a hundred tiles per zoom band to get render time and stored size, multiply by the tile count the coverage implies, and divide the hours by the workers you actually intend to spare — because the difference between “seed to zoom 14” and “seed to zoom 16” is usually the difference between an afternoon and an impossibility.

Core Algorithm and Workflow

Three numbers, each measurable in minutes.

Tile count. At zoom z the world holds 4^z tiles, and the fraction intersecting the coverage scales that down. For a coverage of area A on a planet of surface S, the count at zoom z is approximately 4^z × A / S — accurate enough for planning, and it can be replaced by an exact count from the tiling library when the coverage is irregular.

Stored size. Render a hundred tiles at each zoom band and measure the bytes as the cache will store them, which is not the same as the bytes the renderer produced if compression sits between.

Render time. From the same sample, divided by the number of workers the seed will actually get.

The output that matters is not the total but the per-zoom table, because the decision the estimate exists to inform is where to stop.

The per-zoom table, and the row where the plan changes A table of five zoom levels for a 250 000 square kilometre coverage. Zoom 10 needs 4 800 tiles, 58 megabytes and 0.1 hours. Zoom 12 needs 77 000 tiles, 924 megabytes and 1.6 hours. Zoom 14 needs 1.23 million tiles, 14.8 gigabytes and 26 hours. Zoom 16 needs 19.7 million tiles, 236 gigabytes and 410 hours, which is marked as the row that ends the plan. zoom tiles storage hours at 3 workers 10 4 800 58 MB 0.1 12 77 000 924 MB 1.6 14 1 230 000 14.8 GB 26 16 19 700 000 236 GB 410 The highlighted row is the practical ceiling; the dashed row is what ends the discussion about seeding exhaustively and starts the one about seeding by demand. Producing this table takes about twenty minutes of sampling.
Nobody argues with this table. The argument happens when the numbers are absent and the plan is "seed everything", which sounds equally reasonable for zoom 12 and zoom 16.

Production-Ready Python Implementation

import math

EARTH_KM2 = 510_100_000.0


def sample_zoom_band(render_tile, keys, store_bytes) -> dict:
    """Measure mean render time and stored size for one zoom band.

    `render_tile(key)` returns elapsed seconds; `store_bytes(key)` returns the
    size as the cache will hold it, which may differ from the rendered size.
    """
    if not keys:
        raise ValueError("no sample keys for this zoom band")
    times = [render_tile(k) for k in keys]
    sizes = [store_bytes(k) for k in keys]
    return {"mean_seconds": sum(times) / len(times),
            "mean_bytes": sum(sizes) / len(sizes),
            "sampled": len(keys)}


def estimate_seed(area_km2: float, zooms, samples: dict,
                  seed_workers: int) -> dict:
    """Per-zoom and total tile count, storage and wall-clock for a seed job.

    `seed_workers` is the number allocated to the seed, not the pool size —
    a seed capped at three workers in an eight-worker pool takes 2.7× longer
    than a pool-wide estimate suggests.
    """
    if seed_workers < 1:
        raise ValueError("seed_workers must be at least 1")

    rows = []
    for z in zooms:
        if z not in samples:
            raise KeyError(f"no sample for zoom {z}; every band must be measured")
        tiles = max(1, math.ceil(4 ** z * area_km2 / EARTH_KM2))
        gb = tiles * samples[z]["mean_bytes"] / 1_073_741_824
        hours = tiles * samples[z]["mean_seconds"] / 3600 / seed_workers
        rows.append({"zoom": z, "tiles": tiles,
                     "storage_gb": round(gb, 2), "hours": round(hours, 1)})

    return {"per_zoom": rows,
            "total_tiles": sum(r["tiles"] for r in rows),
            "total_gb": round(sum(r["storage_gb"] for r in rows), 1),
            "total_hours": round(sum(r["hours"] for r in rows), 1),
            "workers_assumed": seed_workers}

Raising when a zoom band has no sample is the guard that keeps the estimate honest. Interpolating the missing band from its neighbours is tempting and wrong: render time per tile does not vary smoothly with zoom, because the feature density that drives it changes as layers switch on at their scale thresholds.

Performance Tuning and Cartographic Best Practices

  • Sample across the coverage, not from one area. A hundred tiles all from the same city measures the expensive tail; a hundred spread across the coverage in proportion to its composition measures the mean. Stratify the sample by land cover or by feature density if the coverage is heterogeneous.
  • Measure the stored size, not the rendered size. Vector tiles are typically halved by gzip and the cache may store either form. The stored figure is the one that fills the disk.
  • Divide by the workers the seed will get. This is the most common source of an optimistic estimate. A concurrency cap of three in an eight-worker pool makes the job 2.7 times longer than a pool-wide calculation suggests.
  • Re-estimate after a style change. Adding a layer changes both render time and tile size, so an estimate is a measurement of a specific style rather than of the coverage.
  • Publish the estimate with the plan. A stakeholder who has seen “236 GB at zoom 16” before the run does not ask why zoom 16 is missing afterwards.
Why every band has to be sampled rather than interpolated Mean render time per tile plotted across zoom bands 8 to 16. The curve is not smooth: it jumps at zoom 12 where the building layer switches on and again at zoom 15 where minor roads and labels appear. A note records that interpolating zoom 13 from zooms 12 and 14 would have underestimated it by 40 per cent. 0 ms 150 300 zoom band 8 10 12 14 16 buildings on minor roads + labels on interpolating z13 here underestimates it by 40% The steps are where layers cross their scale thresholds, which is a property of the style rather than of the pyramid — they move whenever the style does.
The curve has steps because the style has thresholds. Any interpolation across one of them is wrong by whatever that layer costs.

Integration and Next Steps

The estimate is what turns seeding from an operation into a plan. Feed its per-zoom table into the demand ranking from Seeding Tile Caches by Traffic-Weighted Priority to decide where exhaustive seeding stops and demand-driven seeding begins, and into the recovery planning in Recovering a Cold Tile Cache Without Melting the Render Pool, where the same figures say how long a region will be cold.

What the estimate is actually for The estimate feeding three decisions. The per-zoom table decides the exhaustive seeding ceiling. The hours figure, combined with an acceptable window, decides how many workers to allocate. The storage figure decides provisioning and whether an eviction policy is needed at all. per-zoom estimate ~20 min of sampling where exhaustive seeding stops the zoom whose row nobody is willing to pay for how many workers to allocate hours ÷ acceptable window, capped by live-traffic headroom storage to provision and whether an eviction policy is needed at all All three are decisions somebody makes anyway — with the estimate, or without it.
The estimate does not make the seed faster. It moves three decisions from after the run to before it, which is where they are cheap.

Frequently Asked Questions

Why sample per zoom band rather than using one average?

Because tile size and render time vary by an order of magnitude across the pyramid while the tile counts are dominated by the highest zoom. An average taken mostly from cheap low-zoom tiles underestimates the expensive end by a factor that compounds with every level — and the expensive end is exactly where the estimate has to be right. A wrong figure at zoom 8 costs seconds; the same relative error at zoom 15 costs a day.

How accurate does the estimate need to be?

Within a factor of two. The question it answers is not “how long exactly” but “is this hours or weeks, gigabytes or terabytes” — and those answers change the plan, while the difference between 24 and 31 hours does not. Chasing precision beyond a rough order of magnitude spends time on a number whose entire purpose was to avoid spending time.

Does compression change the storage figure much?

For vector tiles, substantially: gzip typically halves them, and whether the cache stores the compressed or decompressed form is a configuration detail that changes the total by a factor of two. For PNG raster tiles the format already carries its own compression and further encoding achieves very little. Measure the size as stored rather than as rendered, because the stored figure is what fills the disk and the two can differ enough to invalidate a provisioning decision.

Should the estimate assume a dedicated render pool?

No, and assuming one is where these estimates most often go wrong. A seed sharing the pool with live traffic gets whatever the concurrency cap allows — often three workers out of eight — so dividing by the pool size produces a figure 2.7 times too optimistic. Divide by the workers the seed will actually be allocated, and state that assumption alongside the result so a later decision to raise the cap can be reflected without redoing the sampling.


Back to Tile Seeding and Cache Warming