Spatial Dashboards Explained: When an App Beats a Map Image
Problem statement
Somebody asks for "a dashboard". Six weeks later there is a Streamlit app nobody opens, and the two numbers people actually wanted are in an email.
An app is the right deliverable when the answer depends on a choice only the reader can make โ which year, which region, which threshold โ and it is the wrong one when a PNG would have done. The difference matters because an app is not a picture: every interaction runs your code again, on a machine you do not own, over data you have to move.
The costs are measurable. Rendering a million points into a pydeck page produces 73.78 MB of HTML (7.95 MB gzipped). A folium map with one marker per point costs about 478 bytes per point: 10,000 markers is 4.78 MB and takes 1.67 s to build; 50,000 is 23.88 MB and 8.68 s.
A static map has none of those costs and answers one question. An app has all of them and answers many.
Quick answer
Choose the deliverable from the question, not from the request:
def deliverable(*, questions, audience_size, data_changes, choices_matter,
needs_own_filters):
if questions == 1 and not choices_matter:
return "a map image โ a PNG in the report"
if questions < 5 and not needs_own_filters:
return "small multiples โ one figure per case"
if data_changes and audience_size > 5:
return "an app"
if needs_own_filters:
return "an app, or a published dataset plus a notebook"
return "a map image, until somebody proves otherwise"
The last line is the honest default. Most requests for a dashboard are requests for an answer, and an answer is smaller, faster and more likely to be read.
Step-by-step solution
1. Write down the questions the app must answer
If the list has one item, it is a figure. If it has forty, it is a data product and the app is a browsing tool for it.
The useful test is whether the reader has to make a choice you cannot make for them. "Unemployment by district, 2025" needs no choice. "Unemployment by district for the year and region I care about" does โ and that is the case where an app earns its cost.
2. Count the audience and the frequency
An app is worth building when the alternative is producing the same figure repeatedly. Five people asking monthly for a variation of one map is sixty figures a year, and that is where the app pays for itself.
One person asking once is one figure. Building an app for it costs a week and produces something with a deployment, a URL, an uptime expectation and a maintenance burden.
3. Establish the browser's budget before designing anything
This is the constraint that most often kills a dashboard late. The measured costs of putting points in a browser:
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 markers, 10,000 4.78 MB 1.67 s
folium markers, 50,000 23.88 MB 8.68 s
folium GeoJson, 50,000 7.34 MB 0.92 s
A 74 MB page is not a slow page, it is a page that does not load. Design the aggregation before the interface.
4. Decide where the compute happens
Three architectures, in increasing order of what the browser must carry:
- Server-side rendering. The app produces an image or a small GeoJSON per interaction. The browser holds little; every interaction is a round trip.
- Client-side with a tile service. The browser draws from tiles and asks the server only for details. Scales to any dataset size.
- Everything in the browser. Simple, and bounded by the measurements above.
Most Streamlit apps are the first by default, which is why their responsiveness depends entirely on how fast the server can re-run.
5. Cache, because every interaction re-runs the script
In Streamlit's model the whole script runs again on every widget change. Measured on a 15 MB shapefile: an uncached app reran in 0.23โ0.25 s, and the same app with @st.cache_data on the loader reran in 0.04โ0.06 s โ four to five times faster, for one decorator.
That ratio holds for anything expensive: without caching, a filter change re-reads the file, re-projects it and rebuilds every derived object.
6. Ship the data alongside the app
Whatever the app does, somebody will want the numbers. A download button costs three lines and removes most requests for "can you send me the underlying data".
It is also the fallback when the app is down, slow, or being viewed by somebody who wanted a spreadsheet.
Code examples
Example 1 โ the budget check, before any interface design
import gzip
import json
def browser_budget(gdf, target_mb=2.0):
"""Can this dataset go into a browser at all, and in what form?"""
payload = gdf.to_json().encode()
raw_mb = len(payload) / 1e6
gz_mb = len(gzip.compress(payload, 6)) / 1e6
print(f"{len(gdf):,} features")
print(f" as GeoJSON {raw_mb:8.2f} MB ({gz_mb:.2f} MB gzipped)")
print(f" as folium markers ~{len(gdf) * 478 / 1e6:6.2f} MB of HTML")
if gz_mb <= target_mb:
print(" โ send it whole")
elif len(gdf) < 200_000:
print(" โ aggregate, simplify, or use pydeck with a binary layer")
else:
print(" โ tiles; this will not fit in a page")
return raw_mb, gz_mb
193,780 features
as GeoJSON 28.11 MB (3.54 MB gzipped)
as folium markers ~92.63 MB of HTML
โ aggregate, simplify, or use pydeck with a binary layer
Example 2 โ the same finding as a figure and as an app
# the figure: one question, no choices, no deployment
def unemployment_map(districts, year=2025, out="unemployment_2025.pdf"):
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(170 / 25.4, 120 / 25.4))
districts.plot(column=f"rate_{year}", cmap="YlGnBu", legend=True, ax=ax)
ax.set_axis_off()
fig.savefig(out)
return out
# the app: the reader chooses, and every choice re-runs the script
def unemployment_app(districts):
import streamlit as st
year = st.sidebar.selectbox("Year", sorted(YEARS, reverse=True))
region = st.sidebar.multiselect("Region", sorted(districts.region.unique()))
threshold = st.sidebar.slider("Highlight above (%)", 0.0, 15.0, 8.0)
subset = districts if not region else districts[districts.region.isin(region)]
st.metric("Districts above threshold",
int((subset[f"rate_{year}"] > threshold).sum()))
st.pyplot(build_map(subset, year, threshold))
st.download_button("Download this selection (CSV)",
subset.drop(columns="geometry").to_csv(index=False),
"selection.csv")
Both are twenty lines. The difference is that the second has a deployment, a URL, an uptime expectation, a rerun cost per interaction and a maintenance burden โ and it answers a question the first cannot.
Example 3 โ measuring the rerun cost before anybody complains
from streamlit.testing.v1 import AppTest
import time
def rerun_cost(app_path, widget_index=0, runs=3):
"""How long does one interaction take? Measured headlessly."""
app = AppTest.from_file(app_path, default_timeout=120)
started = time.perf_counter()
app.run()
first = time.perf_counter() - started
reruns = []
options = sorted(app.selectbox[widget_index].options)
for value in options[1:1 + runs]:
app.selectbox[widget_index].select(value)
started = time.perf_counter()
app.run()
reruns.append(time.perf_counter() - started)
print(f"first run {first:.2f}s reruns {[f'{t:.2f}' for t in reruns]}")
if reruns and min(reruns) > 0.5:
print(" ! over half a second per interaction โ add @st.cache_data "
"to the loaders")
return first, reruns
first run 0.77s reruns ['0.25', '0.23', '0.24'] # uncached
first run 0.43s reruns ['0.05', '0.04', '0.06'] # with @st.cache_data
Explanation
Why an app is a different kind of artefact from a figure
A figure is finished. It has no runtime, no dependencies at view time, no uptime, and it can be emailed, printed and archived.
An app is a running program. It has a deployment, a URL that must keep working, a Python environment that must keep resolving, and a cost per viewer. Those are ongoing obligations, and they are the real price of the "just make it interactive" request.
The question worth asking out loud: who will maintain this in a year, and what happens when they do not?
Why the browser budget decides the architecture
The measurements are not marginal. A million points in a pydeck page is 73.78 MB of HTML; the same points as raw float32 coordinates are 8.0 MB, and as gzipped JSON 6.86 MB.
That difference decides whether the app is a page or a tile client. Deciding it after the interface is built means rewriting the interface, which is why the budget check belongs in the first hour rather than the fourth week.
Why the rerun model surprises people
Streamlit's model is that the whole script runs top to bottom on every interaction. That is what makes it so quick to write and what makes an uncached app feel sluggish: every widget change re-reads the file.
Measured, caching the loader took reruns from 0.23โ0.25 s to 0.04โ0.06 s on a 15 MB shapefile. On a 500 MB one the uncached version is unusable and the cached version is fine โ the same code, one decorator apart.
Why "publish the data" is often the better answer
A large share of dashboard requests are really requests for access: somebody wants to filter, sort and check the numbers themselves.
A published dataset โ a CSV, a GeoPackage, a Parquet file on a URL โ plus a short notebook satisfies that need with no deployment, no uptime and no maintenance. It is worth offering before agreeing to build an app, because it is frequently what the person actually wanted.
Edge cases or notes
- A dashboard nobody opens is a maintenance burden with a URL. Ask who will use it, and how often.
- Every interaction re-runs the script in Streamlit; caching is not optional.
- A 74 MB page does not load. Aggregate, tile, or send binary coordinates.
- Interactive maps hide the projection. Web Mercator distorts area, and users will compare regions.
- An app needs an owner. Figures do not go stale in the same way.
- Add a download button. It removes most follow-up requests.
- Small multiples beat an app for a handful of fixed cases.
- The measured folium marker cost is about 478 bytes each โ use one GeoJson layer instead.
Internal links
- Streamlit, Dash or Panel: choosing a framework for a map app โ if an app is the answer
- What a map app can afford to send to the browser โ the budget in detail
- Reruns and state explained: why your map app redraws everything โ the execution model
- Notebook, app or report: choosing how to ship an analysis โ the wider choice
- How to build a Streamlit app with an interactive map โ the implementation
- How to make interactive maps with folium โ the map itself
- How to build a print-ready map layout in Matplotlib โ the alternative deliverable
- How to package GIS deliverables โ publishing the data instead
FAQ
When is a dashboard the right deliverable?
When the answer depends on a choice only the reader can make, and when the same figure would otherwise be produced repeatedly for several people. One question for one person is a figure.
What does an app cost that a figure does not?
A deployment, a URL with an uptime expectation, an environment that must keep resolving, a rerun cost per interaction, and an owner. Those are ongoing obligations rather than one-off work.
How much data can a map app show?
Measured: pydeck holds 100,000 points in a 7.39 MB page and a million in 73.78 MB, which does not load. Folium markers cost about 478 bytes each, so 50,000 is 23.88 MB.
Why is my Streamlit app slow?
Because the whole script re-runs on every interaction. Caching the loaders took reruns from 0.23โ0.25 s to 0.04โ0.06 s on a 15 MB file.
Should I build an app or publish the data?
Publish first, if the request is really about access. A dataset plus a short notebook has no deployment, no uptime and no maintenance, and it is often what was wanted.
What is the most common mistake?
Designing the interface before checking the browser budget, then discovering in week four that the dataset cannot go into a page at all.