Return a large FeatureCollection as a chunked, gzipped stream that yields one EPSG:4326 feature at a time, so you never buffer the whole payload past Cloud Run’s 32MiB non-streaming cap or spike memory serializing it all at once.

Jump to heading Why this matters

A dashboard that serves vector layers from a sidecar API eventually meets two hard walls at the same moment. First, Cloud Run buffers a non-streaming response entirely before sending it and rejects anything over 32 MiB — a moderately dense boundary layer blows past that easily. Second, building that response by calling gdf.to_json() materializes the entire GeoJSON string, plus Python’s intermediate copies, in memory at once, which can be several times the data’s on-disk size and OOM-kill the container. Both problems have the same solution: stop building one giant object. Stream features one at a time with chunked transfer encoding, and the response is bounded neither by the 32MiB cap nor by a memory spike. This is the large-payload counterpart to the Cloud Run deployment workflow, and it pairs naturally with the async data loading patterns the client uses to consume the stream without blocking.

Buffered full serialization versus chunked feature streamingThe top path shows the whole GeoDataFrame serialized into a single large GeoJSON string that exceeds Cloud Run's 32 mebibyte non-streaming response cap and causes a memory spike, so it is rejected. The bottom path shows a generator yielding the FeatureCollection preamble, then one feature at a time through gzip and chunked transfer encoding to the client, with peak memory staying flat and no response-size cap.Buffered (gdf.to_json)GeoDataFrame200k featuresone 45MB string+ intermediate copies32MiB cap exceededrejected + memory spikeStreaming (chunked feature generator)generatoryield featurepreamblefeature 1feature 2 …gzipchunkedclient maprenders progressivelypeak memory stays flat — one feature resident at a time — and no single-response cap applies

Jump to heading Prerequisites

  • A Cloud Run service that can run a small ASGI/WSGI sidecar next to (or instead of) the dashboard — the base deploy is covered in Cloud Run & Serverless Deployment.
  • Python 3.11+, fastapi>=0.110, uvicorn>=0.29, geopandas>=0.14, shapely>=2.0.
  • Source data as a GeoParquet or GeoPackage file the service can read, with a known CRS.

Jump to heading Step-by-step solution

Jump to heading Step 1 — Build a GeoJSON feature generator

The core idea is a generator that emits valid GeoJSON incrementally: the FeatureCollection preamble, then each feature separated by commas, then the closing brackets. Only one feature’s JSON is ever in memory. Reproject to EPSG:4326 up front because the GeoJSON spec mandates WGS 84 coordinates:

python
import json
from typing import Iterator
import geopandas as gpd
from shapely.geometry import mapping

def stream_geojson(gdf: gpd.GeoDataFrame) -> Iterator[str]:
    """Yield a valid FeatureCollection one feature at a time (EPSG:4326)."""
    if gdf.crs is None or gdf.crs.to_epsg() != 4326:
        gdf = gdf.to_crs("EPSG:4326")  # GeoJSON requires WGS 84 lon/lat

    yield '{"type":"FeatureCollection","features":['
    first = True
    for row in gdf.itertuples(index=False):
        geom = row.geometry
        props = {k: getattr(row, k) for k in gdf.columns if k != "geometry"}
        feature = {
            "type": "Feature",
            "geometry": mapping(geom),   # shapely -> GeoJSON geometry dict
            "properties": props,
        }
        prefix = "" if first else ","
        yield prefix + json.dumps(feature, default=str)
        first = False
    yield "]}"

Jump to heading Step 2 — Return it as a chunked streaming response

Wrap the generator in FastAPI’s StreamingResponse. The server flushes each yielded chunk to the wire immediately, so the container never holds the full body and Cloud Run forwards the chunks as chunked transfer encoding:

python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import geopandas as gpd

app = FastAPI()

@app.get("/layers/{layer}.geojson")
def get_layer(layer: str):
    gdf = gpd.read_parquet(f"data/{layer}.parquet")
    return StreamingResponse(
        stream_geojson(gdf),
        media_type="application/geo+json",
        headers={"Content-Disposition": f'inline; filename="{layer}.geojson"'},
    )

Because the body is streamed, the 32 MiB single-response cap that applies to buffered responses does not bound this endpoint.

Buffered and streamed, drawn as memory over timeBoth curves serve the same one hundred and eighty megabyte feature collection. The buffered response builds the whole body in memory first: its curve climbs for eight seconds to a peak above the instance ceiling, sends everything in one burst, and returns to baseline — and if the ceiling is crossed on the way up, the client receives nothing at all, because no byte was written before the crash. The streamed response holds one chunk at a time: its curve is a flat line a few megabytes above baseline for the whole transfer, while the byte counter below it rises steadily from the first hundred milliseconds. Time to first byte is the second difference: eight seconds against roughly one tenth of a second, because a streamed body starts before the frame is finished being read.SERVING ONE 180 MB FEATURE COLLECTION — CONTAINER MEMORY OVER TIMEinstance memory ceilingbuffered — the whole body before any of itstreamed — one chunk at a time0t=08 s16 sTime to first byte: 8 s buffered against about 0.1 s streamed — the map starts painting while the frame is still being read.And the failure modes differ in kind: a buffered response that crosses the ceiling delivers nothing, because no byte waswritten before the process died. A streamed one has already delivered everything up to the point it stopped.

Jump to heading Step 3 — Gzip the stream

GeoJSON is highly compressible — repeated coordinate structure and property keys often shrink by 70–85%. Compress the stream chunk by chunk so the wire payload stays small without ever buffering the whole thing:

python
import zlib
from typing import Iterator

def gzip_stream(chunks: Iterator[str]) -> Iterator[bytes]:
    """Incrementally gzip a text stream, flushing after each chunk."""
    gz = zlib.compressobj(6, zlib.DEFLATED, zlib.MAX_WBITS | 16)  # gzip header
    for chunk in chunks:
        out = gz.compress(chunk.encode("utf-8"))
        if out:
            yield out
    tail = gz.flush()
    if tail:
        yield tail

@app.get("/layers/{layer}.geojson.gz")
def get_layer_gz(layer: str):
    gdf = gpd.read_parquet(f"data/{layer}.parquet")
    return StreamingResponse(
        gzip_stream(stream_geojson(gdf)),
        media_type="application/geo+json",
        headers={"Content-Encoding": "gzip"},
    )

Jump to heading Step 4 — Paginate by bounding box

Even streamed, sending the whole planet when the user is looking at one city wastes bandwidth. Filter to the requested bounding box using the spatial index so a request only ever streams the current viewport. Accept the bbox in EPSG:4326 and use gdf.cx for an index-backed slice:

python
from fastapi import Query

@app.get("/layers/{layer}/bbox.geojson")
def get_layer_bbox(
    layer: str,
    minx: float = Query(...), miny: float = Query(...),
    maxx: float = Query(...), maxy: float = Query(...),
):
    gdf = gpd.read_parquet(f"data/{layer}.parquet").to_crs("EPSG:4326")
    # .cx uses the spatial index — far cheaper than a full intersection scan
    viewport = gdf.cx[minx:maxx, miny:maxy]
    return StreamingResponse(
        gzip_stream(stream_geojson(viewport)),
        media_type="application/geo+json",
        headers={"Content-Encoding": "gzip"},
    )

A client request such as ?minx=-0.51&miny=51.28&maxx=0.33&maxy=51.69 streams only features intersecting Greater London instead of the national dataset.

What a bounding-box slice actually readsA schematic national extent is divided into a coarse spatial-index grid. The requested viewport, a box covering Greater London, overlaps four cells; those cells are shaded and the rest are left empty. The index-backed slice inspects only the features registered to those four cells, so about eleven thousand features are examined and streamed out of the four hundred and twenty thousand in the dataset. To the right, the same request is annotated with what changes as a result: the response is roughly two point six percent of the national body, the client parses a fraction of the geometry, and the browser paints the viewport rather than a whole country's worth of coordinates it has no pixels to display.?minx=−0.51&miny=51.28&maxx=0.33&maxy=51.69requested viewportnational extent, 420,000 features — shaded cells are the only ones the index opens4 grid cells touchedgdf.cx never scans the other twelve≈ 11,000 features streamed2.6% of the national bodythe client parses 2.6% toobandwidth is rarely the binding cost —browser-side JSON parsing usually isStreaming decides how the bytes leave.The bounding box decides how manythere are to leave in the first place.

Jump to heading Step 5 — Parse incrementally on the client

Consuming the stream feature by feature lets the map paint progressively. A Python client can read the response in chunks; a browser client would use the Fetch streams API the same way:

python
import requests
import ijson  # incremental JSON parser

def consume(url: str):
    with requests.get(url, stream=True) as resp:
        resp.raise_for_status()
        # ijson yields each object under features[] as it arrives
        for feature in ijson.items(resp.raw, "features.item"):
            yield feature  # render / accumulate as each feature streams in

count = sum(1 for _ in consume("https://svc-xxxx.a.run.app/layers/uk_lad.geojson"))
print(f"streamed {count} features")

Jump to heading Verification

Confirm the response is genuinely chunked (not buffered) and that peak memory stays flat regardless of feature count:

python
import requests

resp = requests.get(
    "https://svc-xxxx.a.run.app/layers/uk_lad.geojson.gz", stream=True
)
# Chunked responses carry no fixed Content-Length; they use transfer-encoding.
assert "content-length" not in {k.lower() for k in resp.headers}, \
    "Response is buffered, not streamed — check StreamingResponse wiring"
assert resp.headers.get("Content-Encoding") == "gzip"

total = 0
for chunk in resp.iter_content(chunk_size=64 * 1024):
    total += len(chunk)   # bytes arrive incrementally, not in one buffer
print(f"received {total/1e6:.1f} MB gzipped, streamed in chunks")

Run this against a dataset whose raw GeoJSON exceeds 32 MB: a buffered endpoint would have failed to deploy or errored, while the streamed endpoint returns the full layer cleanly.

Jump to heading Edge cases and gotchas

  • The 32MiB cap only applies to buffered responses. If you accidentally build the full string first — for example wrapping gdf.to_json() in a plain response — you are back under the cap and the memory spike. Verify no fixed Content-Length header is present, which is the tell-tale sign the body was buffered.
  • Full serialization memory is the quieter killer. Even below 32 MB on the wire, gdf.to_json() on a large layer can transiently allocate several times the payload size across Python string copies. The generator keeps peak memory flat at one feature, which matters most on the small memory ceilings typical of serverless instances.
  • CRS must be EPSG:4326 in the output. GeoJSON coordinates are defined as WGS 84 longitude/latitude. Streaming a layer still in EPSG:3857 or EPSG:27700 yields numerically valid but geographically wrong output. Always to_crs("EPSG:4326") before serializing, and reject or reproject a bbox query whose coordinates are not in degrees.

Jump to heading FAQ

What is the actual Cloud Run response size limit?

A non-streaming (buffered) response is capped at 32 MiB on Cloud Run. Streaming responses using chunked transfer encoding are not bound by that single-response cap because the platform forwards chunks as they are produced rather than buffering the whole body. This is why a chunked GeoJSON generator is the correct way to return a large FeatureCollection.

Why must the streamed GeoJSON be in EPSG:4326?

The GeoJSON specification fixes coordinates to WGS 84 longitude and latitude, which is EPSG:4326, and web map clients assume it. If your source GeoDataFrame is in a projected CRS such as EPSG:3857 or EPSG:27700, reproject with to_crs("EPSG:4326") before serializing, otherwise the coordinates will be interpreted as degrees and land in the wrong place or off the map entirely.

How does streaming avoid the memory spike of full serialization?

Serializing an entire GeoDataFrame to one GeoJSON string materializes the whole payload plus intermediate copies in memory at once, which can be several times the on-disk size. A generator that yields one feature at a time holds only a single feature’s JSON in memory, so peak memory stays flat regardless of how many features the response contains.


Back to Cloud Run & Serverless Deployment

Related