What a Map App Can Afford to Send to the Browser

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.00 s
pydeck, 10,000                    0.74 MB       0.01 s
pydeck, 100,000                   7.39 MB       0.06 s
pydeck, 1,000,000                73.78 MB       0.64 s
folium, 10,000 CircleMarkers      4.78 MB       1.67 s
folium, 50,000 CircleMarkers     23.88 MB       8.68 s
folium, 50,000 in one GeoJson     7.34 MB       0.92 s

A 74 MB page is not slow; it does not load. And the folium comparison shows that the same 50,000 features cost 23.88 MB as individual markers and 7.34 MB as one layer โ€” a factor of 3.3 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.

Two panels listing the browser payload budget and the server time budget.
Establish both before designing the interface.

Step-by-step solution

1. Measure the payload, not the row count

Feature size varies by three orders of magnitude. Measured on the same 4,596 administrative polygons: the full GeoJSON was 54.06 MB and a page of fifty features was 857 kB; 193,780 points were 28.11 MB of GeoJSON and 3.54 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 7.34 MB and 0.92 s against 23.88 MB and 8.68 s as markers.
  • pydeck / deck.gl โ€” WebGL, binary-friendly; 100,000 points in a 7.39 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, all measured elsewhere on the same kind of data:

  • Round the coordinates. Six decimal places is about 11 cm; it took a measured payload from 54.06 MB to 29.56 MB โ€” 45%.
  • Simplify the geometry for the zoom the app shows: 54.06 MB to 16.42 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,422 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                 31.77 MB
JSON, gzipped         6.86 MB
Arrow + zstd         11.53 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 15 MB layer: 0.23โ€“0.25 s uncached and 0.04โ€“0.06 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.

Bar chart of payload reductions from rounding, simplification and gzip.
Together these routinely bring a payload inside budget without changing the architecture.

Code examples

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

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 31.8 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
13,464,017 points โ†’ 961,781 cells (7.14%)      # 0.1ยฐ โ€” still too many
13,464,017 points โ†’  71,921 cells (0.53%)      # 0.5ยฐ โ€” a usable payload
13,464,017 points โ†’  23,422 cells (0.17%)      # 1.0ยฐ โ€” comfortable

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

Explanation

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 8.68 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: 7.34 MB and 0.92 s against 23.88 MB and 8.68 s โ€” a 3.3ร— size difference and 9ร— 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: 31.77 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,781 cells at 0.1ยฐ, 71,921 at 0.5ยฐ, 23,422 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.

Bar chart of grid cell counts at four cell sizes from 13.5 million points.
Choosing the cell size by what reads well usually lands inside the budget by itself.

Edge cases or notes

  • 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.78 MB of HTML.
  • Rounding coordinates to 6 dp removed 45% 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.88 MB, and pydeck holds 100,000 points in a 7.39 MB page. A million points in a page is 73.78 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.88 MB to 7.34 MB.

What is the cheapest way to reduce a payload?

Rounding coordinates to six decimal places โ€” about 11 cm โ€” which removed 45% 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,422 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 31.77 MB as JSON and 8.00 MB as raw float32, and deck.gl consumes typed arrays without parsing.