Streamlit, Dash or Panel: Choosing a Framework for a Map App

On this page

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 16.7 MB shapefile in Streamlit: an uncached rerun took 0.10–0.12 s and a cached one 0.02 s (Example 2). The uncached app pays the whole load on every interaction, so that cost grows with the file, 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.

The three execution models behind Streamlit, Dash and Panel. the model is the real differencemodelcostsuitsStreamlitβœ“rerun everythingcaching is load-bearingform-shaped appsDashcallback graphmore wiringhigh interaction ratesPanelreactive parametersa middle pathlarge data viaDatashaderStreamlit removes the callback graph entirely β€” that is why an app takes an afternoon spatialworkflow.io
Leave Streamlit for size, interaction rate or layout control β€” in that order.

Execution models, usage, data size and map component

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.02 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
16.7 MB layer    0.02 s cached          negligible
500 MB layer     ~3 s per rerun*        only the changed callback
5 GB dataset     not viable in-process  tiles or a database, either way

* extrapolated from the measured 0.10 s uncached rerun on 16.7 MB; the other rows are measured or structural.

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.90 MB
folium GeoJson layer tens of thousands 50k features = 8.59 MB, 1.59 s
pydeck / deck.gl hundreds of thousands 100k points = 7.40 MB page; 1M = 73.97 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.

Streamlit rerun times with and without caching on a 15 MB layer. one decorator, measuredfirst run, uncached0.77 srerun, uncached0.23–0.25 sfirst run, cached0.43 srerun, cached0.04–0.06 s00.9 sLarger than most differences between frameworks at the same data size spatialworkflow.io
A slow Streamlit app is usually an uncached one β€” a decorator, not a rewrite.

One US counties app in three frameworks

The data used on this page

The "districts" are the 3,235 US counties and county equivalents of the Census Bureau's 2023 cartographic boundary file at 1:500,000, with the state name as region: districts.shp (the shapefile the timings load) and districts.gpkg. The setup also writes app_uncached.py and app_cached.py, the same Streamlit app with and without @st.cache_data on its loader, for Example 2 to time. Example 1's three apps are framework code that needs its own server, so they are shown rather than run here.

import os

import geopandas as gpd

if not os.path.exists("districts.gpkg"):
    counties = gpd.read_file("https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_500k.zip")
    districts = counties[["GEOID", "NAME", "STATE_NAME", "ALAND", "geometry"]].rename(columns={"STATE_NAME": "region"})
    districts.to_file("districts.gpkg")
    districts.to_file("districts.shp")

APP = """
import streamlit as st
import geopandas as gpd

{decorator}
def load_districts(path):
    return gpd.read_file(path).to_crs(4326)

districts = load_districts("districts.shp")
region = st.selectbox("Region", sorted(districts.region.unique()))
st.write(len(districts[districts.region == region]), "districts")
"""
open("app_uncached.py", "w").write(APP.format(decorator=""))
open("app_cached.py", "w").write(APP.format(decorator="@st.cache_data"))
print(f"{len(gpd.read_file('districts.gpkg')):,} districts; districts.shp {os.path.getsize('districts.shp') / 1e6:.1f} MB")
3,235 districts; districts.shp 16.7 MB

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)

Ten lines of code, thirteen and nine β€” Dash and Panel reusing the Streamlit version's load(). With one widget the three are close; Dash's wiring grows with every input–output pair, and Streamlit's grows instead when partial updates have to be emulated 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


for path in ("app_uncached.py", "app_cached.py"):
    print(path)
    measure_reruns(path)
app_uncached.py
first run  0.28s
reruns    0.12s, 0.10s, 0.11s
app_cached.py
first run  0.22s
reruns    0.02s, 0.02s, 0.02s

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.

The execution model is the real difference

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.10–0.12 s against 0.02 s per interaction β€” about six 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.40 MB for 100,000 points and 73.97 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.

The map component decides how much data an app can show, not the framework. component, not frameworkfolium markersthousandsβ‰ˆ478 bytes of HTML eachfolium GeoJson layertens of thousands50k = 7.34 MB, 0.92 spydeck / deck.glhundreds of thousands100k = 7.39 MB pagea tile serviceanything largerfixed cost per tilepydeck works in Streamlit, Dash and Panel β€” large data does not force a framework spatialworkflow.io
"We need 200,000 points" is a component decision, not a framework one.

Reruns, caches, callbacks and websockets

  • Streamlit reruns the whole script; st.session_state is how anything survives.
  • @st.cache_data for data, @st.cache_resource for 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.

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 16.7 MB layer, reruns went from 0.10–0.12 s to 0.02 s with @st.cache_data β€” about six 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.40 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.