Serving Spatial Data Explained: Files, Features and Tiles

Problem statement

Somebody needs the data. The reflex is to send a file, and it works exactly once โ€” until the data changes, until a second person asks, or until a browser has to display it.

At that point there are three shapes a spatial service can take, and choosing the wrong one is expensive:

  • A file โ€” a GeoPackage, a GeoParquet, a zipped shapefile. Simple, cacheable, and stale the moment it is copied.
  • A feature API โ€” query by bounding box and attribute, get GeoJSON back. Flexible, and its cost is proportional to what it returns.
  • Tiles โ€” pre-cut pyramids of vector or raster data. Fast, cacheable, and only useful for drawing.

The numbers decide more of this than the architecture does. Measured on a 4,596-polygon dataset:

whole dataset as GeoJSON               54.06 MB   (18.05 MB gzipped)
same data at 1 m coordinate precision  29.56 MB   ( 8.85 MB gzipped)
simplified to 0.01ยฐ                    16.42 MB   ( 5.31 MB gzipped)
as GeoParquet                          18.82 MB
one page of 50 features                 0.86 MB   ( 0.30 MB gzipped)
one bounding box, 2 features            0.06 MB   ( 0.02 MB gzipped)

The bounding box is nine hundred times smaller than the file. That ratio is the whole argument for an API.

Quick answer

Match the shape to what the consumer does with the data:

def service_shape(*, consumer, data_changes, dataset_mb, needs_attributes):
    if consumer == "analyst" and not data_changes:
        return "a file โ€” GeoParquet or GeoPackage, on a URL"
    if consumer == "map" and not needs_attributes:
        return "tiles โ€” vector tiles or PMTiles"
    if consumer == "map" and dataset_mb > 20:
        return "tiles, plus a feature API for the click-through"
    if consumer == "application":
        return "a feature API with bbox and attribute filters"
    return "a file, until somebody proves otherwise"

The last line is not a joke. A signed URL to a Parquet file on object storage is the cheapest thing on this list, and it is the right answer more often than the alternatives suggest.

Two panels: a file for analysts, tiles and features for maps and applications.
That is how a feature API ends up returning 54 MB of GeoJSON to a browser.

Step-by-step solution

1. Ask what the consumer does with the response

Three consumers, three needs:

  • An analyst wants the whole thing, once, in a format their tools open. A file is correct.
  • A map wants the part on screen at the current zoom, quickly, and does not need attributes until something is clicked. Tiles are correct.
  • An application wants specific features by attribute or location, and their properties. A feature API is correct.

Most systems have all three consumers and try to serve them with one endpoint, which is how a feature API ends up returning 54 MB of GeoJSON to a browser.

2. Size the response before designing the endpoint

Two numbers settle most design arguments: how big the full dataset is as GeoJSON, and how big a typical response is.

Measured, a bounding-box query returning 2 of 4,596 features was 58.8 kB uncompressed and 20.8 kB on the wire. The same endpoint asked for everything returned 54 MB. An API without a filter is a file download with extra steps.

3. Decide what the client is allowed to ask for

Three limits worth enforcing from the first version:

  • A maximum page size โ€” and a maximum that is genuinely small. Measured, a page of 1,000 features was 12.4 MB and the server managed 2.3 requests per second; a page of 50 was 857 kB at 32.5 requests per second.
  • A required bounding box for anything unbounded, or a default that is not "the world".
  • A coordinate precision limit โ€” six decimal places is about 11 cm, and cutting to it halved the payload in measurement.

4. Separate the drawing path from the querying path

A map drawing a layer needs geometry at the right generalisation, and nothing else. A popup needs one feature's attributes.

Serving both from one GeoJSON endpoint forces the drawing path to carry attributes it will not use and the query path to carry geometry it does not need. Tiles for drawing plus a feature endpoint for the details is more moving parts and much less traffic.

5. Make the second request free

Caching is the cheapest performance work available on an HTTP service. Measured on a precomputed response: the 200 path served 100 requests per second at 94.6 ms, and the 304 path โ€” the same endpoint answering a conditional request โ€” served 1,191 requests per second at 6.2 ms.

Twelve times the throughput, for an ETag header and a comparison.

6. Choose the response format deliberately

GeoJSON is the default because everything reads it, and it is verbose: the same 4,596 features were 54 MB as GeoJSON and 18.8 MB as GeoParquet.

Vector tiles are smaller still and only carry what a renderer needs. Parquet is excellent for an analyst and unreadable to a browser. The format is a property of the consumer, not of the data.

Bar chart of response sizes from 54 MB for the whole dataset to 0.06 MB for a bounding box.
An API without a spatial filter is a download endpoint that costs more to run.

Code examples

Example 1 โ€” the three shapes, side by side

from fastapi import FastAPI, Query, Response
import geopandas as gpd

app = FastAPI()
LAYER = gpd.read_file("provinces.gpkg")


@app.get("/download/provinces.parquet")
def whole_file():
    """Shape 1: the file. One request, cacheable forever, no server work."""
    return Response(open("provinces.parquet", "rb").read(),
                    media_type="application/vnd.apache.parquet",
                    headers={"Cache-Control": "public, max-age=86400"})


@app.get("/features")
def features(bbox: str | None = None, limit: int = Query(50, le=1000), offset: int = 0):
    """Shape 2: the feature API. Cost is proportional to what it returns."""
    subset = LAYER
    if bbox:
        x0, y0, x1, y1 = (float(v) for v in bbox.split(","))
        subset = subset.cx[x0:x1, y0:y1]
    page = subset.iloc[offset:offset + limit]
    return Response(page.to_json(), media_type="application/geo+json")


@app.get("/tiles/{z}/{x}/{y}.mvt")
def tile(z: int, x: int, y: int):
    """Shape 3: tiles. Fixed cost per tile, cacheable, geometry only."""
    return Response(build_mvt(LAYER, z, x, y), media_type="application/vnd.mapbox-vector-tile",
                    headers={"Cache-Control": "public, max-age=3600"})

Example 2 โ€” measuring what your endpoint will actually send

import gzip
import json


def payload_report(gdf, page_sizes=(10, 50, 200, 1000), bbox=None):
    """The numbers that decide the API design, on your own data."""
    whole = gdf.to_json().encode()
    print(f"{'response':28} {'raw':>10} {'gzipped':>10}")
    print(f"{'whole dataset':28} {len(whole) / 1e6:9.2f}M "
          f"{len(gzip.compress(whole, 6)) / 1e6:9.2f}M")

    for n in page_sizes:
        page = gdf.iloc[:n].to_json().encode()
        print(f"{'page of ' + str(n):28} {len(page) / 1e6:9.2f}M "
              f"{len(gzip.compress(page, 6)) / 1e6:9.2f}M")

    if bbox:
        subset = gdf.cx[bbox[0]:bbox[2], bbox[1]:bbox[3]]
        body = subset.to_json().encode()
        print(f"{'bbox (' + str(len(subset)) + ' features)':28} "
              f"{len(body) / 1e6:9.2f}M {len(gzip.compress(body, 6)) / 1e6:9.2f}M")

    import shapely
    reduced = gdf.copy()
    reduced["geometry"] = shapely.set_precision(reduced.geometry.values, 1e-5)
    body = reduced.to_json().encode()
    print(f"{'whole, 1 m precision':28} {len(body) / 1e6:9.2f}M "
          f"{len(gzip.compress(body, 6)) / 1e6:9.2f}M")
response                            raw    gzipped
whole dataset                     54.06M     18.05M
page of 10                         0.22M      0.07M
page of 50                         0.86M      0.30M
page of 200                        3.37M      1.17M
page of 1000                      12.43M      4.31M
bbox (2 features)                  0.06M      0.02M
whole, 1 m precision              29.56M      8.85M

Example 3 โ€” a limits policy expressed as code

from dataclasses import dataclass


@dataclass
class ServiceLimits:
    max_page: int = 200
    default_page: int = 50
    require_bbox_above: int = 500        # features
    max_bbox_area_deg2: float = 25.0
    coordinate_precision: int = 6         # ~11 cm
    max_response_mb: float = 5.0

    def check(self, request_limit, bbox, estimated_features):
        problems = []
        if request_limit > self.max_page:
            problems.append(f"limit {request_limit} exceeds max {self.max_page}")
        if estimated_features > self.require_bbox_above and not bbox:
            problems.append(
                f"{estimated_features:,} features match; a bbox is required above "
                f"{self.require_bbox_above:,}")
        if bbox:
            area = abs((bbox[2] - bbox[0]) * (bbox[3] - bbox[1]))
            if area > self.max_bbox_area_deg2:
                problems.append(f"bbox covers {area:.1f} degยฒ, max is "
                                f"{self.max_bbox_area_deg2}")
        return problems

Writing the limits down as an object makes them reviewable, testable and documentable โ€” and it stops the default page size drifting upwards one pull request at a time.

Explanation

Why the bounding box is the whole point

A feature API without a spatial filter is a download endpoint that costs more to run. With one, the response is proportional to the area asked for โ€” and the measured ratio between a full dataset and a city-sized bounding box was 54.06 MB against 0.06 MB, roughly nine hundred to one.

That is why the bounding box parameter is not a feature to add later. It is the reason the service exists.

Why coordinate precision is free money

GeoJSON writes coordinates as decimal text, and the default is whatever precision the source had โ€” frequently fifteen significant figures, describing a position to a fraction of a nanometre.

Rounding to six decimal places is about 11 cm at the equator, which is finer than the accuracy of almost any dataset. Measured, it took the payload from 54.06 MB to 29.56 MB โ€” a 45% reduction with no visible change to any consumer.

Why the page size matters more than the page count

Pagination is usually introduced to bound the response, and it only works if the page is genuinely small. Measured: a page of 1,000 features was 12.4 MB and the server sustained 2.3 requests per second; a page of 50 was 857 kB at 32.5 requests per second.

The server was not slow. It was serialising and transmitting fourteen times as much data per request, which is exactly what the numbers say.

Why tiles and features are different services

A tile is a fixed-cost, cacheable, geometry-only response designed for a renderer. A feature is a variable-cost, query-driven, attribute-carrying response designed for a program.

Trying to serve a map from a feature API means sending attributes the renderer discards and geometry at full precision the screen cannot show. Trying to serve an application from tiles means reconstructing attributes that were never included. Two consumers, two services โ€” and both can sit on the same data.

Checklist of four API limits and the anti-pattern of having none.
Write them down as an object โ€” defaults drift upward one pull request at a time.

Edge cases or notes

  • A file on object storage is a spatial service and often the right one. Do not skip it.
  • GeoJSON must be EPSG:4326 by specification; other CRSs are legal in practice and confusing.
  • Cache-Control on a file endpoint is free throughput.
  • Gzip is not optional โ€” it took a measured payload from 857 kB to 299 kB on the wire.
  • A default limit with no maximum is a maximum of infinity.
  • Bounding boxes crossing the antimeridian need explicit handling, or they select the whole world.
  • Attribute filters need indexes once the dataset outgrows memory.
  • Measure the payload before designing the endpoint. It settles most arguments.

FAQ

Should I serve a file or build an API?

A file, unless the data changes, the audience is more than one person, or a browser needs part of it. A signed URL to a GeoParquet file is the cheapest service on the list.

How much smaller is a bounding-box response?

Measured on a 4,596-polygon dataset: the whole thing was 54.06 MB and a city-sized bounding box returning 2 features was 58.8 kB โ€” a ratio of about nine hundred to one.

What page size should a feature API use?

Small. A page of 50 features was 857 kB at 32.5 requests per second; a page of 1,000 was 12.4 MB at 2.3 requests per second on the same server.

Should I serve tiles or features?

Both, for different consumers. Tiles are fixed-cost, cacheable and geometry-only for drawing; features are query-driven and carry attributes for applications.

Does coordinate precision matter?

Substantially. Rounding to six decimal places โ€” about 11 cm โ€” took a measured payload from 54.06 MB to 29.56 MB with no visible change.

What is the single cheapest improvement?

Caching. A conditional request answered with 304 served 1,191 requests per second against 100 for the same body returned in full.