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.
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.
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.
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.
Internal links
- Spatial dashboards explained: when an app beats a map image β whether to build one
- How to render a million points in a map app with pydeck β the large-data component
- How to build a Streamlit app with an interactive map β where the budget applies
- Fixing a map app that takes ten seconds to load β the symptom of exceeding it
- How to prepare a GeoDataFrame for the web β the reductions
- Fixing a GeoJSON that is too big for the browser β the same ceiling outside an app
- How to make a hexbin map of points β aggregation that reads well
- Zoom and generalisation explained β how much detail a zoom needs
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.