Serving Raster Tiles with TiTiler
A tile server is the difference between a product that exists and a product people use. Given a Cloud-Optimized GeoTIFF on object storage, a dynamic tiler answers XYZ tile requests by reading the overview level that matches the zoom, rendering a small PNG, and returning it — no pyramid build, no duplicated data, and rendering parameters that can change per request. This topic is part of Visualization, Tiling & Web Delivery.
The work divides into four questions: where the server runs relative to the data, how a tile URL describes what it wants rendered, how many files one layer spans, and what is cached. Everything else is detail.
Prerequisites
pip install "titiler.application>=0.18" "rio-tiler>=6.4" "uvicorn>=0.29" "rio-cogeo>=5.0"
| Package | Minimum version | Why required |
|---|---|---|
titiler.application |
0.18 | The tile endpoints, built on FastAPI |
rio-tiler |
6.4 | Reads the right overview level and renders the tile |
uvicorn |
0.29 | ASGI server to run it |
rio-cogeo |
5.0 | Validating that the sources are actually COGs |
The single hard prerequisite is that the source files are valid Cloud-Optimized GeoTIFFs with overviews — the authoring rules are in writing and validating Cloud-Optimized GeoTIFFs. Serving a striped GeoTIFF technically works and will be slow enough that you will assume the server is broken.
Step-by-Step Workflow
Step 1 — Verify the sources before serving them
from rio_cogeo.cogeo import cog_validate, cog_info
def check_servable(path: str) -> dict:
valid, errors, warnings = cog_validate(path)
info = cog_info(path)
return {
"valid_cog": valid,
"errors": errors,
"overview_levels": len(info.IFD) - 1,
"blocksize": info.IFD[0].Blocksize if info.IFD else None,
"compression": info.Compression,
}
if __name__ == "__main__":
report = check_servable("s3://example-bucket/products/ndvi_36NYF.tif")
assert report["valid_cog"], report["errors"]
assert report["overview_levels"] >= 4, "too few overviews to serve low zooms"
print(report)
The overview-count assertion is the one that saves an afternoon. A file with two overview levels serves the top two zooms well and reads full resolution for everything above that, which looks exactly like a slow server.
Step 2 — Run the server near the data
Placement dominates every other performance decision, which is why the first question about a slow tile service should be geographic rather than technical. A minimal local deployment for development looks like this:
# app.py — a minimal COG tile service
from fastapi import FastAPI
from titiler.core.factory import TilerFactory
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
app = FastAPI(title="Raster tiles")
cog = TilerFactory(router_prefix="/cog")
app.include_router(cog.router, prefix="/cog", tags=["Cloud-Optimized GeoTIFF"])
add_exception_handlers(app, DEFAULT_STATUS_CODES)
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
GDAL_HTTP_MULTIPLEX=YES \
VSI_CACHE=TRUE VSI_CACHE_SIZE=536870912 \
uvicorn app:app --host 0.0.0.0 --port 8000
Those GDAL environment variables are not optional tuning. GDAL_DISABLE_READDIR_ON_OPEN stops every open from listing the whole prefix, which on a bucket with a million objects is catastrophic, and VSI_CACHE keeps recently read byte ranges in memory so panning does not re-fetch the same header repeatedly.
Step 3 — Put the rendering parameters in the URL
A tile URL should fully describe what is being rendered, so that the same URL always produces the same image and a viewer needs no server-side configuration.
/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png
?url=s3://example-bucket/products/ndvi_36NYF.tif
&rescale=-0.2,0.9
&colormap_name=rdylgn
&nodata=nan
For a multi-band source, an expression computes the index on the fly rather than requiring a derived file:
/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png
?url=s3://example-bucket/scenes/S2A_36NYF_20260614.tif
&expression=(b8-b4)/(b8%2Bb4)
&rescale=-0.2,0.9
&colormap_name=rdylgn
The %2B is a URL-encoded plus sign — forgetting it is the single most common cause of a tile endpoint returning a cryptic parse error. Expressions are convenient for exploration and expensive at scale: every tile recomputes the index, so a layer that will be viewed constantly deserves a materialised product built with batch computing indices across a STAC collection.
Step 4 — Cover an archive with a mosaic definition
A single scene is a single URL. An archive is a mosaic definition: a mapping from tile index to the list of files covering it, so the server reads only what a given tile needs.
from cogeo_mosaic.mosaic import MosaicJSON
from cogeo_mosaic.backends import MosaicBackend
scenes = [
"s3://example-bucket/products/ndvi_36NYF.tif",
"s3://example-bucket/products/ndvi_36NYG.tif",
"s3://example-bucket/products/ndvi_37NBA.tif",
]
mosaic = MosaicJSON.from_urls(scenes, minzoom=8, maxzoom=14)
with MosaicBackend("s3://example-bucket/mosaics/ndvi_2026.json", mosaic_def=mosaic) as m:
m.write(overwrite=True)
Served through the mosaic endpoint, those three scenes behave as one continuous layer. Where scenes overlap, the order in the list decides which wins — first match by default — so sort by cloud cover or recency before building, exactly as you would when choosing a pixel selection rule in seamless mosaicking and edge blending.
Step 5 — Cache aggressively
from fastapi import Response
@app.middleware("http")
async def cache_headers(request, call_next) -> Response:
response = await call_next(request)
if request.url.path.startswith("/cog/tiles"):
# Products are immutable once published; version the URL to invalidate
response.headers["Cache-Control"] = "public, max-age=86400, s-maxage=604800"
return response
Immutable products deserve long cache lifetimes, and the way to handle updates is a new URL rather than a shorter lifetime — which is the same versioning discipline described in attaching model metadata to STAC items.
What a Cache Actually Buys
Two properties of map browsing make caching unusually effective here. Tiles are immutable given a URL, so there is no invalidation problem as long as versions live at different addresses. And attention is extremely concentrated: most viewers look at the same handful of places — the study area, the capital city, the demo location — so a small cache captures most traffic.
The corollary is that cache misses are dominated by the long tail of low-interest tiles, which is exactly where cost control matters least. Setting a long s-maxage for the shared cache and a shorter max-age for browsers gives the CDN the work while keeping client behaviour predictable.
Choosing a Deployment Shape
There are three shapes a tile service takes in practice, and the right one depends far more on traffic pattern than on scale.
The serverless option is attractive on paper and disappointing in practice for interactive maps, because the cold start lands on the first tile a user requests — precisely the moment they are forming an impression. Keeping one small instance warm costs a few pounds a month and removes the entire category of complaint.
Whichever shape you choose, containerise it with the same discipline the rest of the stack uses. A tile server needs GDAL, and a GDAL version mismatch between the image that wrote the COGs and the image that reads them is a real source of subtle failures — the pinning approach in pinning GDAL and PROJ versions reproducibly applies without modification.
Two configuration details matter more here than elsewhere. Worker concurrency should be modest — a tile render is I/O bound, so four to eight workers per core is reasonable, and more simply multiplies the memory used by GDAL’s caches. And the container should have enough memory for VSI_CACHE_SIZE plus the decode buffers, which for 512-pixel blocks and eight workers is comfortably under a gigabyte but is not negligible.
Parameter Reference
| Parameter | Type | Default | Usage note |
|---|---|---|---|
url |
str |
— | The COG address; s3://, https:// or a local path |
rescale |
min,max |
dataset range | Always set it; the default renders satellite data almost black |
colormap_name |
str |
none | A named ramp; omit for RGB composites |
expression |
str |
none | Band maths per request, e.g. (b8-b4)/(b8+b4); URL-encode + |
bidx |
int list |
all | Band indices for a composite, e.g. bidx=4&bidx=3&bidx=2 |
nodata |
number or nan |
from file | Override when the file’s nodata is missing or wrong |
resampling |
str |
nearest | bilinear for continuous data at intermediate zooms |
tilesize |
int |
256 | 512 halves the request count for high-density displays |
GDAL_DISABLE_READDIR_ON_OPEN |
env | — | EMPTY_DIR, always, for object storage |
VSI_CACHE_SIZE |
env | small | A few hundred megabytes; keeps headers resident between requests |
Wiring the Endpoint into a Map
A tile endpoint is only useful once something consumes it, and the consumer side is three lines in every common client. The URL template is the contract: whatever parameters were chosen above travel with it, so a colleague handed the template sees exactly what you saw.
import leafmap
TILE_URL = (
"https://tiles.example.org/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png"
"?url=s3://example-bucket/products/ndvi_36NYF.tif"
"&rescale=-0.2,0.9&colormap_name=rdylgn"
)
m = leafmap.Map(center=(0.35, 34.75), zoom=10)
m.add_tile_layer(TILE_URL, name="NDVI 2026-06", attribution="Sentinel-2")
m
The same template drops straight into a web map client, a desktop GIS as an XYZ connection, or a static site. That portability is the practical argument for putting every rendering parameter in the URL rather than in server configuration: the layer becomes a string that can be emailed, and nobody has to reproduce a server-side state to see what you see.
For a published product it is worth generating that template automatically from the catalog record, so the rescale values and colour map come from the same place the figure renderer reads them. The alternative — a template pasted into a wiki months ago — drifts silently the first time the product’s value range changes. Discovering the products in the first place is the job of the catalog, as described in querying STAC catalogs programmatically, and the notebook side of consuming these layers is covered in interactive raster exploration in Jupyter.
Verification & Testing
Test the endpoint the way a map will use it: a burst of adjacent tiles, not one tile in isolation.
import time
import httpx
BASE = "http://localhost:8000/cog/tiles/WebMercatorQuad"
PARAMS = {"url": "s3://example-bucket/products/ndvi_36NYF.tif",
"rescale": "-0.2,0.9", "colormap_name": "rdylgn"}
def timed_tiles(z: int, x0: int, y0: int, n: int = 4) -> None:
sizes, times = [], []
with httpx.Client(timeout=30) as client:
for dx in range(n):
for dy in range(n):
t0 = time.perf_counter()
r = client.get(f"{BASE}/{z}/{x0 + dx}/{y0 + dy}.png", params=PARAMS)
times.append(time.perf_counter() - t0)
r.raise_for_status()
sizes.append(len(r.content))
times.sort()
print(f"{n * n} tiles · median {times[len(times)//2]*1000:.0f} ms "
f"· p95 {times[int(len(times)*0.95)]*1000:.0f} ms "
f"· mean {sum(sizes)/len(sizes)/1024:.1f} KB")
Healthy numbers for a same-region deployment are a median under 60 ms and tiles between 5 and 30 KB. A median in the hundreds of milliseconds points at placement or missing overviews; tiles over 100 KB usually mean the rendering is producing noise, which in turn usually means the rescale is wrong.
Also verify the visual result against the figure path. Render the same extent with matplotlib using the same limits and ramp, and compare: if they differ, one of the two has a different nodata or stretch, and the discrepancy will eventually reach a stakeholder.
Troubleshooting
Tiles return 500 with a parse error on expression
The + in the expression was not URL-encoded, so it arrived as a space. Encode it as %2B, or build the URL with a library rather than string concatenation.
The first tile after a pause is slow, then everything is fast
The header cache expired and the file’s directory had to be re-read. Raise VSI_CACHE_SIZE, and keep the server warm rather than scaling to zero if latency matters more than idle cost.
The layer is blank at low zoom and correct at high zoom
The mosaic’s minzoom is above the zoom being requested, or the scenes have too few overviews to cover it. Both are fixable at build time.
Colours differ between two scenes in the same mosaic
Each scene is being rescaled on its own statistics. Pass explicit rescale values so the whole mosaic shares one stretch.
Costs are higher than expected
Every uncached tile is a set of range requests against object storage, and those are billed. Check the cache hit rate first; the guidance in reducing S3 egress costs in raster pipelines applies directly.
Frequently Asked Questions
Q: Does dynamic tiling really scale, or should I pre-render? It scales for almost every case once a CDN is in front, because repeated views hit the cache and only the first request per tile touches the server. Pre-rendering still wins for a global basemap at very high request rates where every tile is requested constantly.
Q: Why are my tiles slow even though the file is a COG? Usually the server is far from the storage, so every byte-range request pays cross-region latency, or the file has no overviews so low zooms read full resolution. Check both before tuning anything else.
Q: Can one endpoint serve many scenes as a single layer? Yes, through a mosaic definition that maps tile indices to the files covering them. The server reads only the files a given tile touches, so a thousand-scene archive behaves as one seamless layer without any merged file existing.
Q: How do I stop the world reading my private archive? Keep the bucket private and let the tile server hold the credentials, exposing only rendered tiles. Then put authentication in front of the tile endpoint itself; without it, a public tiler pointed at a private bucket is a public bucket with extra steps.
Q: Should the tile server also serve the raw file for download? Usually yes, through a separate route or simply by making the object readable. Analysts want the COG itself so they can run windowed reads against it, and forcing them to scrape tiles to reconstruct data is worse for everyone: it is slower, lossier and far more expensive in requests than one ranged read of the original. Serve the tiles for looking and the file for working, and document both next to the product.
Related
- Deploying TiTiler for a COG Archive — container, configuration and placement in detail.
- Adding Dynamic Rescaling and Colormaps to Tile URLs — the rendering parameters and their pitfalls.
- Serving Mosaics with MosaicJSON — covering an archive with one endpoint.
- Caching and CDN Strategies for Raster Tiles — headers, hit rates and invalidation by versioning.
- Reading a COG over S3 without Downloading — the read mechanics every tile depends on.