What a Map App Can Afford to Send to the Browser

On this page

Problem statement

A map app has two budgets and both are easy to exceed without noticing: what the server can produce per interaction, and what the browser can hold and draw.

The second is the harder ceiling, and it is measurable. Putting points into a page:

                                HTML size    build time
pydeck, 1,000 points              0.08 MB       0.01 s
pydeck, 10,000                    0.74 MB       0.01 s
pydeck, 100,000                   7.40 MB       0.09 s
pydeck, 1,000,000                73.97 MB       0.98 s
folium, 10,000 CircleMarkers      4.78 MB       1.42 s
folium, 50,000 CircleMarkers     23.90 MB       7.16 s
folium, 50,000 in one GeoJson     8.59 MB       1.59 s

The points are GeoNames places (Example 4). A 74 MB page is not slow; it does not load. And the folium comparison shows that the same 50,000 features cost 23.90 MB as individual markers and 8.59 MB as one layer β€” a factor of 2.8 for a choice of API.

The budget has to be established before the interface, because exceeding it is not fixable by tuning.

Quick answer

Work out what the payload will be before designing anything:

import gzip


def budget_check(gdf, target_gz_mb=2.0):
    payload = gdf.to_json().encode()
    raw = len(payload) / 1e6
    gz = len(gzip.compress(payload, 6)) / 1e6
    markers = len(gdf) * 478 / 1e6            # measured: ~478 B per folium marker

    print(f"{len(gdf):,} features")
    print(f"  GeoJSON            {raw:8.2f} MB  ({gz:.2f} MB gzipped)")
    print(f"  as folium markers ~{markers:8.2f} MB of HTML")

    if gz <= target_gz_mb:
        return "send it whole"
    if len(gdf) <= 200_000:
        return "aggregate or simplify, and use pydeck rather than markers"
    return "tiles β€” this cannot go into a page"

Working figures for a comfortable app: under about 2 MB gzipped on the wire, under about 100,000 features drawn at once, and under about 200 ms per interaction on the server.

A map app has a browser budget and a server budget. two budgets, different unitsbrowser β€” bytes and objectsβœ•under ~2 MB gzipped on the wireβœ•under ~100,000 features drawnβœ•folium markers: ~478 B eachβœ•exceeding it is not fixable by tuningserver β€” time per interactionβœ•under ~200 ms feels responsiveβœ•over ~1 s feels brokenβœ•measured: 0.24 s uncached, 0.05 s cachedβœ•a form batches expensive filtersThe browser budget is the harder ceiling and the one discovered latest spatialworkflow.io
Establish both before designing the interface.

Payload, component cost, reduction, aggregation

1. Measure the payload, not the row count

Feature size varies by three orders of magnitude. Measured on 4,596 administrative polygons: the full GeoJSON was 68.35 MB and a page of fifty features was 1,011 kB; GeoNames' 236,014 cities500 points were 28.89 MB of GeoJSON and 2.86 MB gzipped.

So "how many features can I show?" has no answer without the geometry. Measure the payload for your own data at several sizes before choosing a component.

2. Know what each component costs

  • folium markers β€” one HTML/JS object per point, measured at about 478 bytes each. Fine to a few thousand.
  • folium GeoJson layer β€” one layer object; 50,000 features measured at 8.59 MB and 1.41 s against 23.90 MB and 7.10 s as markers.
  • pydeck / deck.gl β€” WebGL, binary-friendly; 100,000 points in a 7.40 MB page.
  • tiles β€” fixed cost per tile, independent of dataset size. The only option above a few hundred thousand features.

3. Reduce the payload before changing the component

Three reductions, all cheap, measured on the provinces in Example 1:

  • Round the coordinates. Six decimal places is about 11 cm; it took the payload from 68.35 MB to 46.44 MB β€” 32%.
  • Simplify the geometry for the zoom the app shows: 68.35 MB to 30.68 MB at 0.01Β°.
  • Drop the properties nobody displays. Every attribute is repeated per feature in GeoJSON.

Together these routinely take a payload below the threshold without changing the architecture.

4. Aggregate when the reduction is not enough

A million points is not a map, it is a density surface. Aggregating to a grid, a hexbin or an administrative area turns an impossible payload into a small one and produces a more readable map.

Measured, aggregating 13.5 million points to a one-degree grid produced 23,460 cells β€” a payload three orders of magnitude smaller than the points, and the map showed the pattern more clearly.

5. Send binary when the points must be individual

For a scatter of hundreds of thousands of points, the encoding matters. Measured on a million coordinate pairs:

JSON                 21.97 MB
JSON, gzipped         8.98 MB
Arrow + zstd         14.92 MB
raw float32           8.00 MB

deck.gl accepts typed arrays directly, which skips JSON parsing in the browser entirely β€” the difference between a page that hangs for several seconds and one that does not.

6. Set a server-side budget too

The browser budget is about bytes; the server budget is about time per interaction. Streamlit re-runs the whole script, so every widget change pays the full cost.

Measured on a 16.7 MB layer in How to cache spatial data in a map app: 0.10–0.12 s per rerun uncached and 0.02 s cached. Under about 200 ms an app feels responsive; over about a second it feels broken, and a form or debouncing becomes necessary rather than optional.

Three cheap payload reductions applied to the same layer. before changing the componentas-is68.35 MBcoordinates at 6 dp46.44 MB (βˆ’32%)simplified 0.01Β°30.68 MBgzipped as-is20.80 MBsimplified + gzipped7.50 MB070 MBRounding alone removes 32% and changes nothing a consumer can detect spatialworkflow.io
Together these routinely bring a payload inside budget without changing the architecture.

A budget report for province polygons

The data used on this page

Real layers throughout. The polygons are Natural Earth's 1:10m admin-1 states and provinces (provinces.gpkg, 4,596 features). The points are GeoNames: cities500 (every populated place of 500 people or more) and the full allCountries dump, downloaded on 28 September 2026 as Parquet with lat/lon. The page-size tests in Example 4 draw their 1,000 to 1,000,000 points from the full dump with a fixed seed.

import os
import zipfile

import geopandas as gpd
import numpy as np
import pandas as pd
import requests

GEONAMES = ["geonameid", "name", "asciiname", "alternatenames", "lat", "lon", "feature_class",
            "feature_code", "country_code", "cc2", "admin1", "admin2", "admin3", "admin4",
            "population", "elevation", "dem", "timezone", "modified"]


def fetch(url, path):
    if not os.path.exists(path):
        with requests.get(url, stream=True, timeout=600) as reply:
            reply.raise_for_status()
            with open(path, "wb") as out:
                for chunk in reply.iter_content(1 << 20):
                    out.write(chunk)


if not os.path.exists("provinces.gpkg"):
    gpd.read_file("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_1_states_provinces.zip") \
       .to_file("provinces.gpkg")
for name, columns in (("cities500", None), ("allCountries", ["geonameid", "lat", "lon", "feature_class"])):
    target = "cities500.parquet" if name == "cities500" else "geonames_all.parquet"
    if not os.path.exists(target):
        fetch(f"https://download.geonames.org/export/dump/{name}.zip", f"{name}.zip")
        with zipfile.ZipFile(f"{name}.zip") as z, z.open(f"{name}.txt") as f:
            pd.read_csv(f, sep="\t", names=GEONAMES, quoting=3, keep_default_na=False, usecols=columns,
                        dtype={"country_code": str, "cc2": str, "admin1": str, "admin2": str,
                               "admin3": str, "admin4": str, "feature_class": str}).to_parquet(target)
print(len(pd.read_parquet("geonames_all.parquet", columns=["lat"])), "GeoNames points")
13472317 GeoNames points

Example 1 β€” the full budget report

import gzip
import io


def payload_report(gdf, precision=6, simplify_deg=None):
    """Everything that decides whether this dataset can go into a page."""
    import shapely

    rows = []

    def measure(label, frame):
        body = frame.to_json().encode()
        rows.append((label, len(body) / 1e6,
                     len(gzip.compress(body, 6)) / 1e6))

    measure("as-is", gdf)

    rounded = gdf.copy()
    rounded["geometry"] = shapely.set_precision(rounded.geometry.values,
                                                10 ** -precision)
    measure(f"{precision} dp", rounded)

    if simplify_deg:
        simplified = gdf.copy()
        simplified["geometry"] = simplified.geometry.simplify(
            simplify_deg, preserve_topology=True)
        measure(f"simplified {simplify_deg}Β°", simplified)

    minimal = gdf[[gdf.geometry.name]]
    measure("geometry only", minimal)

    print(f"{len(gdf):,} features")
    print(f"{'variant':22} {'raw MB':>9} {'gzip MB':>9}")
    for label, raw, gz in rows:
        flag = "  ok" if gz <= 2.0 else ""
        print(f"{label:22} {raw:9.2f} {gz:9.2f}{flag}")

    print(f"\nfolium markers would be ~{len(gdf) * 478 / 1e6:,.1f} MB of HTML")
    print(f"pydeck holds 100,000 points in ~7.4 MB; a million is ~74 MB")
    return rows


provinces = gpd.read_file("provinces.gpkg")
_ = payload_report(provinces, simplify_deg=0.01)
page = provinces.iloc[:50].to_json().encode()
print(f"a page of fifty: {len(page) / 1e3:,.0f} kB")
4,596 features
variant                   raw MB   gzip MB
as-is                      68.35     20.80
6 dp                       46.44     12.39
simplified 0.01Β°           30.68      7.50
geometry only              53.90     17.94

folium markers would be ~2.2 MB of HTML
pydeck holds 100,000 points in ~7.4 MB; a million is ~74 MB
a page of fifty: 1,011 kB

Example 2 β€” choosing the component from the numbers

from dataclasses import dataclass


@dataclass(frozen=True)
class RenderPlan:
    component: str
    reduction: str
    note: str


def plan_rendering(feature_count: int, gzipped_mb: float,
                   needs_interaction: bool = True) -> RenderPlan:
    if gzipped_mb <= 2.0 and feature_count <= 10_000:
        return RenderPlan("folium GeoJson", "none",
                          "small enough to send whole; Leaflet plugins available")
    if feature_count <= 100_000:
        return RenderPlan("pydeck", "round coordinates, drop unused properties",
                          "measured: 100k points in a 7.4 MB page")
    if feature_count <= 1_000_000 and not needs_interaction:
        return RenderPlan("pydeck with binary attributes",
                          "typed arrays, not JSON",
                          "1M as JSON is 22.0 MB; as float32, 8.0 MB")
    return RenderPlan("a tile service", "aggregate or tile",
                      "beyond a page: fixed cost per tile, any dataset size")

Example 3 β€” aggregating instead of sending points

import numpy as np
import pandas as pd


def to_grid(points_df, cell_deg=0.05, lon="lon", lat="lat", value=None):
    """A million points becomes a few thousand cells β€” and a clearer map."""
    grid = points_df.assign(
        cell_lon=(np.floor(points_df[lon] / cell_deg) * cell_deg).round(6),
        cell_lat=(np.floor(points_df[lat] / cell_deg) * cell_deg).round(6))

    aggregations = {"n": ("cell_lon", "size")}
    if value:
        aggregations["mean"] = (value, "mean")

    cells = (grid.groupby(["cell_lon", "cell_lat"], as_index=False)
             .agg(**aggregations))

    print(f"{len(points_df):,} points β†’ {len(cells):,} cells "
          f"({100 * len(cells) / len(points_df):.2f}%)")
    return cells


everything = pd.read_parquet("geonames_all.parquet", columns=["lat", "lon"])
for cell in (0.1, 0.25, 0.5, 1.0):
    _ = to_grid(everything, cell_deg=cell)
13,472,317 points β†’ 961,925 cells (7.14%)
13,472,317 points β†’ 224,457 cells (1.67%)
13,472,317 points β†’ 71,990 cells (0.53%)
13,472,317 points β†’ 23,460 cells (0.17%)

The reduction is not a compromise. At a million points the map was a solid blob; at 23,460 cells the pattern is legible, which is the actual goal.

Example 4 β€” the page sizes on this page

import gzip
import time

import json

import folium
import pydeck as pdk
import pyarrow as pa
import pyarrow.ipc as ipc

pool = everything.sample(1_000_000, random_state=0).reset_index(drop=True)


def html_mb(path):
    return os.path.getsize(path) / 1e6


for n in (1_000, 10_000, 100_000, 1_000_000):
    started = time.perf_counter()
    deck = pdk.Deck(layers=[pdk.Layer("ScatterplotLayer", pool.iloc[:n], get_position=["lon", "lat"],
                                      get_radius=200)], map_style=None)
    deck.to_html("deck.html", open_browser=False)
    print(f"pydeck, {n:>9,} points    {html_mb('deck.html'):7.2f} MB   {time.perf_counter() - started:5.2f} s")

for n in (10_000, 50_000):
    started = time.perf_counter()
    m = folium.Map(location=[20, 0], zoom_start=2, tiles=None)
    for lat, lon in zip(pool.lat.values[:n], pool.lon.values[:n]):
        folium.CircleMarker([lat, lon], radius=2).add_to(m)
    m.save("markers.html")
    print(f"folium, {n:,} CircleMarkers   {html_mb('markers.html'):7.2f} MB   {time.perf_counter() - started:5.2f} s")
per_marker = os.path.getsize("markers.html") / 50_000

started = time.perf_counter()
sample = gpd.GeoDataFrame(geometry=gpd.points_from_xy(pool.lon[:50_000], pool.lat[:50_000]), crs=4326)
m = folium.Map(location=[20, 0], zoom_start=2, tiles=None)
folium.GeoJson(sample).add_to(m)
m.save("layer.html")
print(f"folium, 50,000 in one GeoJson     {html_mb('layer.html'):7.2f} MB   {time.perf_counter() - started:5.2f} s")
print(f"about {per_marker:.0f} bytes per CircleMarker")

cities = pd.read_parquet("cities500.parquet", columns=["lat", "lon"])
body = gpd.GeoDataFrame(geometry=gpd.points_from_xy(cities.lon, cities.lat), crs=4326).to_json().encode()
print(f"cities500: {len(cities):,} points, {len(body) / 1e6:.2f} MB GeoJSON, "
      f"{len(gzip.compress(body, 6)) / 1e6:.2f} MB gzipped")

pairs = pool[["lon", "lat"]].to_numpy()
as_json = json.dumps(pairs.tolist()).encode()
sink = pa.BufferOutputStream()
with ipc.new_file(sink, pa.schema([("lon", pa.float64()), ("lat", pa.float64())]),
                  options=ipc.IpcWriteOptions(compression="zstd")) as writer:
    writer.write_table(pa.table({"lon": pool.lon, "lat": pool.lat}))
print(f"a million pairs: JSON {len(as_json) / 1e6:.2f} MB, gzipped {len(gzip.compress(as_json, 6)) / 1e6:.2f} MB, "
      f"Arrow+zstd {sink.getvalue().size / 1e6:.2f} MB, float32 {pairs.astype('float32').nbytes / 1e6:.2f} MB")
pydeck,     1,000 points       0.08 MB    0.06 s
pydeck,    10,000 points       0.74 MB    0.02 s
pydeck,   100,000 points       7.40 MB    0.08 s
pydeck, 1,000,000 points      73.97 MB    0.93 s
folium, 10,000 CircleMarkers      4.78 MB    1.46 s
folium, 50,000 CircleMarkers     23.90 MB    7.10 s
folium, 50,000 in one GeoJson        8.59 MB    1.41 s
about 478 bytes per CircleMarker
cities500: 236,014 points, 28.89 MB GeoJSON, 2.86 MB gzipped
a million pairs: JSON 21.97 MB, gzipped 8.98 MB, Arrow+zstd 14.92 MB, float32 8.00 MB

Why the browser's ceiling is lower than the server's

Why the browser ceiling is lower than the server's

A server pushing 30 MB is working normally. A browser receiving it must parse the JSON, build objects for every feature, and hand them to a renderer β€” and the DOM-based ones build an element per feature.

That is the measured folium marker cost: about 478 bytes of HTML per point, and 7.10 s to build 50,000 of them. The server produced that in under a second; the browser is where it becomes unusable.

Why one layer beats many markers

A folium CircleMarker is a JavaScript object with its own options, its own event handlers and its own DOM element. Fifty thousand of them is fifty thousand objects.

A single GeoJson layer is one object containing fifty thousand geometries, styled by one function. Measured: 8.59 MB and 1.41 s against 23.90 MB and 7.10 s β€” a 2.8Γ— size difference and 5.0Γ— on build time, for using the layer API instead of the marker API.

Why binary encodings matter at scale

JSON is text: every coordinate is parsed from a decimal string into a float, and every feature builds an object. At a million points that parsing is seconds of blocked main thread.

Typed arrays skip it. Measured on a million coordinate pairs: 21.97 MB as JSON against 8.00 MB as raw float32, and deck.gl can consume the second form directly. The size saving is useful; not parsing it is the larger win.

Why aggregation is usually the right answer, not a compromise

A million overlapping points renders as a solid shape whose density nobody can read. Aggregating to cells produces a map that shows the pattern, and it happens to be three orders of magnitude smaller.

The measured ladder β€” 13.5 million points to 961,925 cells at 0.1Β°, 224,457 at 0.25Β°, 71,990 at 0.5Β°, 23,460 at 1Β° β€” is a cartographic decision that also solves the payload problem. Choosing the cell size by what reads well usually lands inside the budget without trying.

Aggregating 13.5 million points to a grid, at four cell sizes. aggregation is better cartography and a smaller payload0.10Β° cells961,927coarserΓ· 4.30.25Β° cells224,457coarserΓ· 3.10.50Β° cells71,990coarserΓ· 3.11.00Β° cells23,460At a million points the map is a solid blob; at 23,460 cells the pattern is legible spatialworkflow.io
Choosing the cell size by what reads well usually lands inside the budget by itself.

Measured marker costs, gzip and rounding

  • Measure your own geometry. Polygons and points differ by orders of magnitude.
  • Gzip is on by default in most servers and roughly a factor of three on GeoJSON.
  • The folium marker cost is about 478 bytes each β€” measured, and it is the ceiling people hit first.
  • pydeck's page embeds the data; a million points is 73.97 MB of HTML.
  • Rounding coordinates to 6 dp removed 32% of a measured payload with no visible change.
  • Aggregation improves the map, not just the payload.
  • Server budget: about 200 ms per interaction before an app feels sluggish.
  • Check the payload before designing the interface, not after.

FAQ

How many features can a map app show?

Measured: 10,000 folium markers is 4.78 MB of HTML, 50,000 is 23.90 MB, and pydeck holds 100,000 points in a 7.40 MB page. A million points in a page is 73.97 MB, which does not load.

What is a reasonable payload budget?

About 2 MB gzipped on the wire and under about 100,000 features drawn at once, with under 200 ms per server interaction.

Why is folium so much heavier than pydeck?

It builds a DOM object per marker β€” measured at about 478 bytes each. Using one GeoJson layer instead of 50,000 markers took 23.90 MB to 8.59 MB.

What is the cheapest way to reduce a payload?

Rounding coordinates to six decimal places β€” about 11 cm β€” which removed 32% of a measured payload with no perceptible change.

Should I aggregate or send the points?

Aggregate above roughly a hundred thousand. A million overlapping points is an unreadable blob; 23,460 grid cells is a legible map and three orders of magnitude smaller.

Does a binary encoding help?

Substantially at scale. A million coordinate pairs is 21.97 MB as JSON and 8.00 MB as raw float32, and deck.gl consumes typed arrays without parsing.