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.
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.
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.
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
Rangeis 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.