Serving COG Range Requests Without a Tile Server

The whole point of the Cloud-Optimized GeoTIFF layout is that a client can fetch the few kilobytes it needs rather than the whole file. When that works, a web map reads a four-gigabyte orthomosaic directly from object storage and there is no tile server to deploy, scale, monitor or pay for.

When it does not work, the failure is almost never in the raster. It is in the storage configuration — CORS headers, range support, or a transport encoding that should not be there. This page covers getting that right and proving it, as part of publishing rasters to STAC and object storage.

What the client actually does

A client opening a COG makes a small, predictable sequence of requests, and understanding it explains every configuration requirement that follows.

First it fetches the header — the first few kilobytes, holding the TIFF image file directory, the geo-referencing tags and the offsets of every internal tile. From that it knows the raster’s extent, its overview levels, and exactly where in the file any given tile lives.

Then it decides which overview level matches the zoom being displayed, and issues one range request per tile it needs from that level. At a low zoom that is a handful of small reads against a coarse pyramid level; at full zoom it is a handful of reads against full-resolution tiles.

Nothing else is transferred. A four-gigabyte mosaic viewed at a regional zoom costs perhaps two hundred kilobytes.

The request sequence a client makes against a Cloud-Optimized GeoTIFF A file layout is drawn as a long horizontal bar. At its start is the header block, holding the image file directory, georeferencing tags and the byte offsets of every internal tile. After it come the overview levels from coarsest to finest, and finally the full-resolution tile data occupying most of the length. Three arrows show the client's requests: one small request for the header, then one request per tile needed from the matching overview level, and nothing else. A note states that a four-gigabyte mosaic viewed at a regional zoom costs roughly two hundred kilobytes. header overviews, coarse → fine full-resolution tiles 1 2 1 — header: directory, geo tags, tile offsets 2 — one range request per needed tile at the matching level Nothing else is transferred. A 4 GB mosaic at regional zoom costs about 200 KB.

Figure 1 — Two kinds of request, and no third.

Minimal reproducible solution

The bucket configuration is short, and every line of it exists because omitting it produces a specific failure.

import json


def cors_configuration(origins: list[str]) -> dict:
    """CORS rules that let a browser read a COG by range request.

    Both header lists are mandatory and are the usual omission. Without Range
    in the allowed headers the preflight fails; without Content-Range and
    Content-Length in the exposed headers the fetch succeeds but the client
    cannot tell which bytes it received, which surfaces as a corrupt-file
    error rather than as a permissions problem.
    """
    return {
        "CORSRules": [{
            "AllowedOrigins": origins,
            "AllowedMethods": ["GET", "HEAD"],
            "AllowedHeaders": ["Range", "If-None-Match", "Origin"],
            "ExposeHeaders": [
                "Content-Range", "Content-Length",
                "Accept-Ranges", "ETag",
            ],
            "MaxAgeSeconds": 3600,
        }]
    }


def apply_cors(client, bucket: str, origins: list[str]) -> None:
    client.put_bucket_cors(
        Bucket=bucket, CORSConfiguration=cors_configuration(origins))

Wildcard origins are acceptable for genuinely public deliverables and a mistake for anything else — a permissive CORS policy on a private bucket does not grant access, but it does let any page attempt it, which turns an authorisation boundary into an audit problem.

Proving it works from the outside

Configuration that looks right and a service that behaves are different claims. Three checks, each catching a distinct failure.

import requests


def probe_cog_endpoint(url: str, *, origin: str) -> dict:
    """Exercise exactly what a browser will do, and report what fails.

    Testing from Python without an Origin header passes in cases where a
    browser will fail, because CORS is only enforced cross-origin. Sending the
    header is what makes this probe representative.
    """
    problems = []
    headers = {"Origin": origin}

    head = requests.head(url, headers=headers, timeout=20)
    if head.status_code >= 400:
        problems.append(f"HEAD returned {head.status_code}")
    if head.headers.get("Accept-Ranges") != "bytes":
        problems.append("Accept-Ranges is not bytes — ranges unsupported")
    if head.headers.get("Content-Encoding"):
        problems.append(
            f"Content-Encoding {head.headers['Content-Encoding']} breaks ranges")

    allowed = head.headers.get("Access-Control-Allow-Origin")
    if allowed not in (origin, "*"):
        problems.append(f"origin {origin} not allowed (got {allowed})")

    exposed = head.headers.get("Access-Control-Expose-Headers", "").lower()
    for needed in ("content-range", "content-length"):
        if needed not in exposed:
            problems.append(f"{needed} is not exposed to the browser")

    part = requests.get(url, headers={**headers, "Range": "bytes=0-4095"},
                        timeout=20)
    if part.status_code != 206:
        problems.append(f"range request returned {part.status_code}, want 206")
    elif part.content[:2] not in (b"II", b"MM"):
        problems.append("first bytes are not a TIFF header")
    elif len(part.content) != 4096:
        problems.append(f"asked for 4096 bytes, received {len(part.content)}")

    return {"ok": not problems, "problems": problems,
            "bytes_for_header": len(part.content)}

The length check at the end catches the transport-compression failure that every other check passes: a gzipped object returns a 200 with the whole file, or a 206 with the wrong byte count, and a client reading tile offsets from those bytes produces nonsense.

When a tile server is still the answer

Direct range reads are not universally better. Four situations genuinely call for a server in front.

On-the-fly reprojection. A COG in a national grid served to a web map in Web Mercator has to be reprojected somewhere. A client can do it, slowly and per tile; a server does it once and caches.

Dynamic styling. Changing the stretch on a float32 DEM, applying a colour ramp, or compositing bands on demand is server work. Baking a styled 8-bit visual product is the alternative, and often the better one.

Access control per user. Object storage grants access to an object. Anything finer — this client sees this site only — needs a layer that knows who is asking.

Very high concurrency. A public map with thousands of simultaneous viewers issues an enormous number of small requests. A tile cache in front collapses them.

When direct range reads suffice and when a tile server is required Two columns. The direct column covers a raster already in the display projection, fixed styling baked into an eight-bit visual product, access granted at the object level, and modest concurrency; the note is that no server is needed at all. The tile server column covers on-the-fly reprojection from a national grid to Web Mercator, dynamic styling such as changing the stretch on a float elevation model, per-user access control finer than object permissions, and very high concurrency where a cache collapses many small requests. direct range reads suffice already in the display projection fixed styling, baked into 8-bit visual access granted at the object level modest concurrency no server to deploy or pay for a tile server is required reprojection from a national grid dynamic stretch on a float DEM per-user access, finer than objects very high concurrency a cache collapses many small reads

Figure 2 — The four reasons, and no others worth deploying for.

Putting a CDN in front

Object storage answers range requests correctly and answers them from one region. A viewer on the other side of the world pays that latency on every tile, and at high zoom a map view is a dozen sequential-ish requests, so the round trips add up into something the user perceives as sluggishness rather than as distance.

A CDN removes most of that, provided it is configured to pass ranges through rather than to normalise them. Three settings decide it: range requests must be forwarded to the origin rather than collapsed into a full-object fetch, the Range header must be part of the cache key so two different byte ranges are not served the same cached response, and the CORS headers set at the origin must be forwarded rather than stripped.

Because deliverable keys are never overwritten, the cache policy can be maximally aggressive — the immutable Cache-Control set at upload does the work, and no invalidation is ever needed for pixel data. The catalogue JSON is the exception and wants a short time to live, which is a good reason to keep it on a separate path prefix with its own cache behaviour rather than trying to express both policies in one rule.

How each configuration fault presents to whoever reports it Three rows. A missing Range entry in the allowed headers presents as the map simply never loading the layer, with a preflight failure visible only in the browser's own console and nothing at all in the server logs. Missing Content-Range in the exposed headers presents as a corrupt file error, because the client receives bytes it cannot locate within the file and concludes the raster is damaged. A transport encoding set on the object presents as a layer that works when the whole file is downloaded and fails in every browser, which reliably sends people to investigate the raster rather than the storage. Range not allowed the layer never loads; visible only in the browser console Content-Range not exposed reported as a corrupt file — the client cannot place the bytes a transport encoding set works on download, fails in every browser — sends people to the raster None of the three reports itself as a configuration problem, which is why the probe matters.

Figure 3 — Three faults, three misleading symptoms.

Edge-case matrix

Situation Effect Handling
Range missing from allowed headers Preflight fails Add it to AllowedHeaders
Content-Range not exposed Reads as file corruption Add it to ExposeHeaders
Content-Encoding set Wrong bytes returned Remove it at the origin
Raster striped, not tiled Whole rows fetched Re-export as a valid COG
No overviews Full resolution at every zoom Build overviews on export
CDN strips Range Silent whole-file transfer Configure range pass-through
Wildcard CORS on private data Audit exposure Enumerate the origins
Probe without an Origin header Passes, browser fails Send Origin in the probe

Verification snippet

def cost_of_a_view(url: str, *, origin: str, tiles: int = 12) -> dict:
    """Estimate what one map view actually transfers.

    If this is not far smaller than the file, the layout is not working — the
    usual causes are a striped raster or missing overviews, both of which
    validate as GeoTIFFs and neither of which behaves as a COG.
    """
    head = requests.head(url, headers={"Origin": origin}, timeout=20)
    total = int(head.headers.get("Content-Length", 0))

    probe = requests.get(url, headers={"Origin": origin,
                                       "Range": "bytes=0-16383"}, timeout=20)
    header_bytes = len(probe.content)
    estimate = header_bytes + tiles * 64 * 1024

    return {
        "file_bytes": total,
        "estimated_view_bytes": estimate,
        "fraction": estimate / total if total else 1.0,
        "behaving_as_cog": (estimate / total if total else 1.0) < 0.02,
    }

When to escalate

  • The probe passes and the browser still fails. Look at a CDN or proxy between the two. Stripping or rewriting Range is the usual cause and is invisible from a direct probe against the origin.
  • Every zoom transfers the full resolution. The raster has no overviews. Re-export it; no amount of serving configuration compensates.
  • Reads are correct but slow. Latency per request dominates at high zoom. A CDN in front, or a larger internal tile size on export, both help — and the second is free.

Publishing Rasters to STAC and Object Storage