Streaming FlatGeobuf Features Over HTTP Range Requests
Streaming FlatGeobuf features over HTTP range requests lets a client read only the features inside a query bounding box directly from an .fgb object on S3 or Azure Blob, issuing a short sequence of 206 Partial Content reads against the packed spatial index instead of downloading the entire archive. This guide is for data engineers and application teams serving interactive maps or extract jobs from warm-tier object storage who need predictable latency and predictable egress as archives grow into the tens of gigabytes. It explains how a bounding-box query becomes a set of byte ranges, the exact requests to issue against cloud stores, the CDN behaviors that silently break range support, and the latency crossover where a full download beats streaming. It builds directly on the packed Hilbert R-tree produced in building a FlatGeobuf spatial index for HTTP range reads and sits within the broader Format Conversion & Pipeline Automation workflow.
The Range-Request Round Trip
A bounding-box read is not one request; it is a short, ordered conversation between the client and the object store:
The first read pulls the header and the top of the index in one shot, resolving the schema, CRS, and the R-tree’s root node. The client then walks the tree: it descends only the nodes whose bounding boxes intersect the query rectangle, issuing follow-up range reads for deeper node blocks as needed. Traversal terminates at a set of leaf offsets, each pointing at a contiguous run of features in the payload. The final reads fetch exactly those feature byte ranges. Because Hilbert ordering keeps spatially adjacent features adjacent on disk, the matched features usually coalesce into a handful of contiguous ranges rather than hundreds of scattered ones — which is what keeps the request count, and therefore the latency, bounded. The write-side guarantee that makes this hold is covered under FlatGeobuf Optimization Techniques.
Each read is a network round trip, so the total latency of a query is roughly the number of dependent range requests multiplied by the store’s time-to-first-byte. Against a warm tier that is typically tens of milliseconds per request, and a tight bounding box resolves in three to five reads. That is the entire economic argument: you pay for a few kilobytes of transfer and a few round trips instead of egressing a whole object, which is why FlatGeobuf owns the range-read web-delivery tier while analytical extracts belong to the columnar GeoParquet Migration Workflows path.
Issuing Range Requests Against Object Storage
The steps below take a bounding-box query from a raw HTTP probe to a working GDAL client that reads only the intersecting features.
- Confirm the store and its edge honor byte ranges. Object stores support ranges natively, but a fronting CDN may not. Probe with an explicit range and inspect the status line — you need
206, not200.
# A GET (not HEAD) reveals whether ranges are actually served end to end.
curl -s -r 0-65535 -o /dev/null -D - \
https://spatial-archive.s3.us-west-2.amazonaws.com/fgb/parcels/2024/region_north_indexed.fgb
# Expected:
# HTTP/1.1 206 Partial Content
# Accept-Ranges: bytes
# Content-Range: bytes 0-65535/1974330112
# Content-Length: 65536
- Run the bounding-box query through GDAL’s range-reading virtual filesystem. GDAL reads
.fgbover/vsis3/(or/vsicurl/for a public URL) using the spatial index and HTTP ranges automatically;pyogrioexposes this with abboxfilter. Only the intersecting features are materialized.
import pyogrio
# bbox is (minx, miny, maxx, maxy) in the file's CRS (EPSG:4326 here).
# GDAL walks the R-tree and issues range reads under the hood — the whole
# 1.9 GB object is never downloaded.
gdf = pyogrio.read_dataframe(
"/vsis3/spatial-archive/fgb/parcels/2024/region_north_indexed.fgb",
bbox=(-122.72, 45.44, -122.55, 45.60), # Portland west-side window
columns=["parcel_id", "zoning", "assessed_value"],
)
print(len(gdf), "features fetched inside the window")
- Account for the bytes actually transferred. Enable GDAL’s cURL tracing to see each range request and prove the read footprint matches the bounding box rather than the file size.
# CPL_CURL_VERBOSE logs every Range request GDAL issues for one query.
export CPL_CURL_VERBOSE=YES
export CPL_DEBUG=ON
export AWS_REGION=us-west-2
python query_bbox.py 2>&1 | grep -E "Range: bytes|Content-Range" | head
# Each line is one 206 read; sum the Content-Length values for total bytes moved.
Streaming Versus Buffering the Response
A range request returns a byte range, not a feature boundary, so a streaming reader has to reassemble records that straddle the edges of the ranges it asked for. How that is handled decides whether the client renders progressively or waits for everything.
The practical guidance is to size the chunks the reader requests to the transport rather than to the data: 256 KB to 1 MB per chunk keeps round trips amortised while giving the renderer something to draw several times a second. Requesting the whole matched range in one call is simpler and costs the progressive rendering that makes a large extent feel responsive.
Validation & Verification
Prove three things before trusting a range-streaming endpoint in production: the store returns 206, the query returns only in-window features, and the transferred bytes are a small fraction of the object.
# 1. Range support and object size in one probe.
curl -s -r 0-1023 -o /dev/null -D - \
https://spatial-archive.s3.us-west-2.amazonaws.com/fgb/parcels/2024/region_north_indexed.fgb \
| grep -E "206|Content-Range"
# Expected: HTTP/1.1 206 Partial Content
# Content-Range: bytes 0-1023/1974330112
# 2. Spatial correctness + byte-footprint sanity check.
import pyogrio, shapely.geometry as sg
bbox = (-122.72, 45.44, -122.55, 45.60)
gdf = pyogrio.read_dataframe(
"/vsis3/spatial-archive/fgb/parcels/2024/region_north_indexed.fgb", bbox=bbox
)
window = sg.box(*bbox)
# Every returned geometry must intersect the query window (the index is exact
# at the leaf, so no far-away features should appear).
assert gdf.geometry.intersects(window).all(), "index returned out-of-window features"
print(f"{len(gdf)} features; expected a few thousand, not all 482,817")
Expected result: a query over a neighborhood-scale window against a ~1.9 GB layer returns a few thousand features and moves on the order of a few hundred kilobytes — three to four orders of magnitude less than a full download. If the returned count approaches the full feature count, the index was bypassed and the client fell back to a scan.
CDN and Range-Support Caveats
Range streaming lives or dies on the transport layer between the client and the object bytes. The failure modes are specific and quiet.
| Symptom | Root cause | Fix |
|---|---|---|
First probe returns 200 OK with the full Content-Length, and every query downloads the whole file |
A fronting CDN or proxy strips the Range header or is configured to collapse ranges into a full-object fetch |
Enable range/origin passthrough and range-based caching at the edge; verify Accept-Ranges: bytes survives to the client |
HTTP 416 Range Not Satisfiable on a valid offset |
The object was replaced with a shorter version but a stale edge cache still advertises the old length | Purge the edge cache on republish and key the cache on the object’s ETag, not just the path |
| Query latency spikes to seconds and the request log shows dozens of tiny reads | Attribute-heavy features fragment the payload, or the file predates the index and clients scan linearly | Prune attributes at write time and rebuild with the packed index; confirm contiguous matched ranges in the cURL trace |
There is a real crossover point. Each range read is a dependent round trip, so a query that must return most of the file — a country-wide extract, or a layer small enough that the whole object fits in one read — is faster and cheaper as a single full GET than as many ranged reads, because it avoids per-request overhead and index-traversal latency. Use range streaming for selective, bounding-box-scoped reads against large objects in warm and cold tiers; fall back to a full download when the selection ratio is high. The request-count and egress economics that decide this crossover for a given archive are modeled in the spatial archive cost model, and the store’s per-request pricing depends on the object storage selection for GIS archives you standardized on.
For the authoritative range semantics on each platform, see the AWS S3 GetObject Range documentation and the Azure Blob range GET documentation, and confirm your client against the FlatGeobuf specification for HTTP reader behavior.
Failure Modes Unique to Ranged Reads
Ranged access introduces failure modes that whole-file downloads do not have, and most of them return data rather than an error — which is why they need explicit checks rather than reliance on exception handling.
The stale-cache row is the one that causes the most confusion in practice, because the data is internally consistent — an old index paired with old features — and only wrong relative to what the catalogue advertises. Publishing each generation at its own path removes the failure entirely and costs nothing but a directory level, which is why archives that serve range reads should never republish in place.
Operational Execution Checklist
Frequently Asked Questions
How many concurrent range requests should a client issue?
Six to eight against a single host is the practical ceiling, matching what browsers allow per origin over HTTP/1.1 and roughly where object-storage per-connection throughput stops improving. Over HTTP/2 the connection limit disappears but the storage-side benefit does not grow much beyond a dozen streams. More important than the number is ordering: request the ranges covering the centre of the viewport first, so the progressive draw fills the part of the map the reader is looking at.
Does a failed range request need a full retry?
No — retry only the failed range. Because each request is independent and addressed by byte offset, a transient failure costs one range, not the session. Implement retries with jitter on 5xx and on truncated responses, and treat a 416 as fatal rather than retryable, since it means the index and the object disagree and no number of attempts will fix that.
Can range reads work against an archive-tier object?
Only after a restore, and only against the restored temporary copy. Objects in instant-retrieval archive classes support ranges directly and are the right home for a delivery copy that must stay reachable; objects in Flexible Retrieval or Deep Archive do not, so the pattern for those is to keep the delivery copy in an instant class and the preservation copy in the deepest tier the retention policy allows.
Related
- Up: FlatGeobuf Optimization Techniques — the parent reference for the
.fgbindex, schema, and CRS tuning this streaming path relies on. - Building a FlatGeobuf Spatial Index for HTTP Range Reads — how the packed R-tree that these range reads traverse is written and verified.
- Converting GeoPackage to FlatGeobuf for Web Archives — producing the range-readable objects this workflow queries.
- Spatial Archive Cost Modeling — the request-count and egress math behind the range-versus-full-download crossover.
- Object Storage Selection for GIS Archives — matching a store’s range and per-request pricing to a streaming access pattern.
Part of the Spatial Data Archival knowledge base.