Streamlit, Dash or Panel: Choosing a Framework for a Map App
Problem statement
Four frameworks can put an interactive map in a browser from Python, and they differ in ways that only become apparent after the app is written:
- Streamlit โ the whole script re-runs on every interaction. Fastest to write, and that model is either delightful or the source of every performance problem, depending on the data.
- Dash โ an explicit callback graph. More code, and it re-runs only what depends on the changed input.
- Panel โ a reactive object model over the Holoviz stack, with the best story for large datasets through Datashader.
- Jupyter plus ipyleaflet or ipywidgets โ no deployment at all, if the audience already uses notebooks.
The choice that matters is the execution model, because it decides how the app behaves when the data grows. Measured on a 15 MB shapefile in Streamlit: an uncached rerun took 0.23โ0.25 s and a cached one 0.04โ0.06 s. On a 500 MB file the uncached version is unusable, and no amount of interface work fixes it.
Quick answer
def framework_for(*, data_mb, interactions_per_minute, audience,
team_python_only, needs_precise_layout):
if audience == "notebook users":
return "ipyleaflet in Jupyter โ no deployment at all"
if data_mb > 500 or interactions_per_minute > 30:
return "Dash or Panel โ partial updates matter at this size"
if needs_precise_layout:
return "Dash โ full control of the layout, at the cost of code"
if team_python_only and data_mb < 200:
return "Streamlit โ fastest to write, rerun model is fine here"
return "Streamlit, with caching, and revisit if it gets slow"
Streamlit is the right default for most internal spatial apps. The reasons to leave it are size, interaction rate and layout control โ in that order.
Step-by-step solution
1. Understand the three execution models
Streamlit: rerun everything. Every widget change re-executes the script top to bottom. State lives in st.session_state; expensive work is kept out of the rerun with @st.cache_data and @st.cache_resource.
Dash: callbacks. You declare which outputs depend on which inputs, and only those callbacks fire. More boilerplate, and the app does not redo unrelated work.
Panel: reactive. Parameters and dependencies are declared on objects; the framework recomputes what changed. It sits between the other two in verbosity and integrates with Holoviews, Datashader and Bokeh.
The model shows up in the code volume for a trivial app and in the performance for a large one.
2. Match the model to how often the app is touched
A form-shaped app โ pick a region, pick a year, look at a map โ is touched a few times per session, and a rerun costing 0.05 s is invisible.
A continuously interactive app โ a slider dragged, a map panned with live filtering โ fires dozens of updates per minute, and a full rerun each time is the wrong shape however fast it is.
3. Match the framework to the data size
The browser budget is the same in every framework, and the server-side cost is not:
rerun everything partial update
15 MB layer 0.05 s cached negligible
500 MB layer seconds per rerun only the changed callback
5 GB dataset not viable in-process tiles or a database, either way
Above about half a gigabyte, the framework stops being the constraint and the architecture starts: tiles for drawing, a database or DuckDB for querying, and the app as a thin client over both.
4. Choose the map component deliberately
The map is a separate decision from the framework, and it has a much bigger effect on what the app can show:
| Component | Suits | Measured cost |
|---|---|---|
| folium / leaflet | thousands of features, familiar | markers โ 478 bytes each; 50k markers = 23.88 MB |
| folium GeoJson layer | tens of thousands | 50k features = 7.34 MB, 0.92 s |
| pydeck / deck.gl | hundreds of thousands | 100k points = 7.39 MB page; 1M = 73.78 MB |
| a tile service | anything larger | fixed cost per tile |
pydeck works in Streamlit, Dash and Panel, so a large-data requirement does not by itself force a framework.
5. Weigh deployment, not just development
- Streamlit โ one command, and a hosted option. Sessions are per browser tab and hold their own state.
- Dash โ a Flask application; deploys like any WSGI/ASGI app, behind gunicorn.
- Panel โ a Bokeh server; similar deployment, with a websocket to keep working.
- Jupyter โ no deployment if the audience runs notebooks; a substantial one if they do not.
Websocket-based frameworks need a proxy configured for websockets, which is the single most common deployment surprise.
6. Be honest about the exit cost
An app written in one framework is not portable to another: the state model, the callback structure and the layout are all framework-specific.
That argues for starting with the simplest thing that can work โ usually Streamlit โ and treating a move to Dash or Panel as a deliberate rewrite justified by a measured problem, rather than as a precaution.
Code examples
Example 1 โ the same app in three models
# Streamlit: the script is the app; everything re-runs
import streamlit as st
@st.cache_data
def load():
import geopandas as gpd
return gpd.read_file("districts.gpkg")
districts = load()
region = st.selectbox("Region", sorted(districts.region.unique()))
subset = districts[districts.region == region]
st.metric("Districts", len(subset))
st.pydeck_chart(build_deck(subset))
# Dash: declare what depends on what; only that callback fires
from dash import Dash, dcc, html, Input, Output
app = Dash(__name__)
districts = load()
app.layout = html.Div([
dcc.Dropdown(sorted(districts.region.unique()), id="region"),
html.Div(id="count"),
dcc.Graph(id="map"),
])
@app.callback(Output("count", "children"), Output("map", "figure"),
Input("region", "value"))
def update(region):
subset = districts[districts.region == region]
return f"{len(subset)} districts", build_figure(subset)
# Panel: reactive parameters
import panel as pn
import param
class DistrictApp(param.Parameterized):
region = param.Selector(objects=sorted(load().region.unique()))
@param.depends("region")
def view(self):
subset = load()[load().region == self.region]
return pn.Column(f"### {len(subset)} districts", build_pane(subset))
pn.serve(DistrictApp().view)
Twelve lines, twenty and eighteen. The gap widens with the number of interacting widgets, and it narrows again when the app needs partial updates that Streamlit has to emulate with caching.
Example 2 โ measuring the rerun cost in Streamlit
from streamlit.testing.v1 import AppTest
import time
def measure_reruns(app_path, runs=3):
app = AppTest.from_file(app_path, default_timeout=120)
started = time.perf_counter()
app.run()
first = time.perf_counter() - started
times = []
options = sorted(app.selectbox[0].options)
for value in options[1:1 + runs]:
app.selectbox[0].select(value)
started = time.perf_counter()
app.run()
times.append(time.perf_counter() - started)
print(f"first run {first:5.2f}s")
print(f"reruns {', '.join(f'{t:.2f}s' for t in times)}")
return first, times
uncached: first run 0.77s reruns 0.25s, 0.23s, 0.24s
cached: first run 0.43s reruns 0.05s, 0.04s, 0.06s
Run this before choosing a framework for performance reasons. Streamlit with caching is frequently fast enough, and a rewrite to Dash to fix a problem that a decorator would have fixed is an expensive lesson.
Example 3 โ a portable core, whatever the framework wraps it
"""Keep the analysis out of the framework, so the app is a thin shell."""
from dataclasses import dataclass
import geopandas as gpd
@dataclass(frozen=True)
class Selection:
region: str | None = None
year: int = 2025
threshold: float = 8.0
def apply_selection(districts: gpd.GeoDataFrame, selection: Selection):
subset = districts
if selection.region:
subset = subset[subset["region"] == selection.region]
column = f"rate_{selection.year}"
return subset.assign(above=subset[column] > selection.threshold)
def summarise(subset) -> dict:
return {"districts": len(subset),
"above_threshold": int(subset["above"].sum()),
"median_rate": float(subset.filter(like="rate_").iloc[:, -1].median())}
Every framework then reduces to reading widgets into a Selection and rendering the result. That is what makes a later move to another framework a day rather than a month โ and it makes the logic testable without any framework at all.
Explanation
Why the execution model is the real difference
Streamlit's rerun model removes an entire category of code: no callbacks, no state wiring, no dependency graph. That is why a working app takes an afternoon.
The cost is that the framework cannot know what changed, so it re-runs everything and relies on caching to make that cheap. When the expensive work is cacheable โ loading a file, fitting a model โ that works well. When it is not โ a computation that depends on the widget that just changed โ the rerun is the work.
Dash and Panel invert the trade: more wiring, and the framework knows exactly what to recompute.
Why caching matters more than the framework choice
The measured difference between an uncached and a cached Streamlit app was 0.23โ0.25 s against 0.04โ0.06 s per interaction โ four to five times, from one decorator.
That is larger than most differences between frameworks at the same data size. A slow Streamlit app is usually an uncached one, and the fix is a decorator rather than a rewrite.
Why the map component matters more than the framework for data size
pydeck, folium and a tile client all work in all three frameworks. The measured costs are properties of the component, not the framework: folium markers at about 478 bytes each, pydeck at 7.39 MB for 100,000 points and 73.78 MB for a million.
So "we need to show 200,000 points" is a map-component decision. It becomes a framework decision only when the server-side filtering of those points is too slow to redo on every rerun.
Why keeping the logic out of the framework pays twice
An app whose analysis lives inside callbacks or Streamlit script bodies cannot be tested without the framework, cannot be reused in a batch job, and cannot be moved.
Extracting the selection and the summary into plain functions costs nothing at the time and gives you unit tests, a reusable core, and a migration path that is a day of wiring rather than a rewrite.
Edge cases or notes
- Streamlit reruns the whole script;
st.session_stateis how anything survives. @st.cache_datafor data,@st.cache_resourcefor connections and models.- Dash callbacks must have unique outputs โ two callbacks writing one output is an error.
- Panel and Bokeh need websockets through any proxy.
- pydeck works in all three, so large-data support is not a framework property.
- Streamlit sessions are per tab; memory scales with concurrent viewers.
- Test the rerun cost before switching frameworks for performance.
- Keep the analysis in plain functions โ it is the only portable part.
Internal links
- Spatial dashboards explained: when an app beats a map image โ whether to build one at all
- Reruns and state explained: why your map app redraws everything โ the Streamlit model
- How to build a Streamlit app with an interactive map โ the implementation
- How to cache spatial data in a map app โ the decorator that matters most
- What a map app can afford to send to the browser โ the map-component costs
- How to render a million points in a map app with pydeck โ the large-data component
- How to deploy a Python map app with Docker โ the deployment differences
- How to test a map app without a browser โ testing the portable core
FAQ
Which framework should I use for a map app?
Streamlit for most internal apps: fastest to write, and fine with caching. Move to Dash or Panel for large data, high interaction rates, or precise layout control.
What is the real difference between them?
The execution model. Streamlit re-runs the whole script on every interaction; Dash fires only the callbacks that depend on the changed input; Panel recomputes reactive dependencies.
Is Streamlit too slow for real data?
Usually not, with caching. Measured on a 15 MB layer, reruns went from 0.23โ0.25 s to 0.04โ0.06 s with @st.cache_data โ four to five times, from one decorator.
Does the framework limit how much data I can map?
No โ the map component does. pydeck holds 100,000 points in a 7.39 MB page and works in all three frameworks; folium markers cost about 478 bytes each.
What is the deployment difference?
Streamlit is one command; Dash deploys like any Flask app; Panel runs a Bokeh server. Both Panel and Streamlit need websockets through the proxy, which is the usual surprise.
How do I keep my options open?
Put the analysis in plain functions and let the app read widgets into a small selection object. Then a framework change is a day of wiring rather than a rewrite.