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

Where the tile server sits decides the latency A tile server in the same region as the bucket pays a few milliseconds per range request, so a tile needing four requests renders quickly. A server in another region pays tens of milliseconds per request and the same tile takes an order of magnitude longer. A server on a laptop reading from the cloud is slower still and useful only for development. Latency per tile, by placement same region 2-5 ms per range request 4 requests + render ~30 ms per tile cross region 60-90 ms per range request same 4 requests ~300 ms per tile laptop to cloud home or office link plus variable jitter 0.5-2 s per tile A map pane shows roughly 12 tiles at once Same region: the view fills in about a third of a second. Cross region: three and a half seconds. No amount of server tuning closes that gap — only moving the compute does.

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

Origin load against cache hit rate With no cache every tile request reaches the tile server and the storage behind it. At a seventy per cent hit rate origin load falls to under a third. At the ninety-five per cent typical of a published layer after its first day, the origin handles one request in twenty, and both the compute bill and the egress bill fall with it. Requests reaching the origin, per 1,000 tile views 1000 500 300 50 no cache 50% hit 70% hit 95% hit A published layer settles near 95% within a day, because people look at the same places. That is the difference between one server and twenty.

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.

Serverless, container, or always-on A serverless function costs nothing when idle but pays a cold start on the first request after a pause. A container service on a small always-warm instance has no cold start and a modest fixed cost. A dedicated always-on group suits sustained heavy traffic and is wasteful for anything less. Match the shape to how the layer is used serverless function idle cost: none cold start: 1-3 s scales instantly on burst for: occasional internal use small warm container idle cost: low and fixed cold start: none CDN absorbs the bursts for: most published layers always-on group idle cost: real cold start: none predictable at high load for: sustained public traffic The CDN in front matters more than this choice: at a 95% hit rate the origin barely notices the difference. Start warm and small, measure the hit rate, and only then decide whether anything needs changing.

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.


Deep-Dive Articles