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.

Grid of three app frameworks with their execution model, cost and best fit.
Leave Streamlit for size, interaction rate or 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.

Bar chart of Streamlit first-run and rerun times with and without caching.
A slow Streamlit app is usually an uncached one โ€” a decorator, not a rewrite.

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.

Four map components stacked by the number of features each can carry.
"We need 200,000 points" is a component decision, not a framework one.

Edge cases or notes

  • 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 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.