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.
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.
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.
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.
Related
- Seeding Tile Caches by Traffic-Weighted Priority — ordering the job this estimate sizes.
- Recovering a Cold Tile Cache Without Melting the Render Pool — the same figures applied to a recovery window.
Back to Tile Seeding and Cache Warming