A two-second pan emits about ninety bounds events. Guard the expensive work on a timestamp in session state, keep the newest bounds rather than the first, and set the window near the query’s own latency — at which point the debounce is invisible.

Jump to heading Why this matters

Map components report their viewport continuously. A single unhurried pan across a city produces tens of bounds payloads, and in some Leaflet versions a stationary pointer hovering over the map produces them too. Bound naively to a spatial query, each one triggers a rerun, a bounding-box filter and a re-serialisation of the result — and every one of those except the last is thrown away before a human sees it.

The waste is not the interesting part. The interesting part is that the queries queue: they run in order on a single-threaded script, so by the time the ninetieth event arrives the dashboard is still working through the twentieth, and the map appears to lag several seconds behind the pointer. Users describe this as slowness. It is not slowness — the individual query is fast — it is ninety of them.

Where the drag events goOne two-second drag of the map produces about ninety pointer-move events. Bound directly to the filter, all ninety trigger a rerun and a spatial query, of which eighty-nine are discarded before anyone sees them. A three-hundred millisecond trailing debounce collapses the same drag to a single query fired once the pointer settles. A leading-plus-trailing debounce fires twice — once immediately so the interface acknowledges the gesture, and once at the end with the settled bounds — which is usually the arrangement that feels fastest without costing more than two queries.ONE 2-SECOND PAN — QUERIES ISSUEDbound to every event90 queries89 wastedtrailing debounce, 300 ms1 query, on settleleading + trailing2 queries — one to acknowledge, one that is rightA leading edge matters more than it looks: without it the map is silent for 300 ms after the gesture ends, which readsas lag rather than as batching. With it, something happens immediately and the accurate answer replaces it.

Jump to heading Prerequisites

  • streamlit>=1.36 and streamlit-folium>=0.20, or an equivalent component that returns viewport bounds.
  • The bounds-equality guard from syncing dropdown filters with map boundaries. Debouncing is the next layer on top of it: the equality guard removes events that changed nothing, and the debounce removes events that changed something you do not yet care about.
  • A cached query function, so the events that do get through are cheap when they repeat.

Jump to heading Step-by-step solution

Jump to heading Step 1 — Understand what a debounce can and cannot stop

Where to put the debounceThe debounce can sit in three places and the choice decides what it can protect. In the browser, inside the map component's own event handling, it prevents the event from ever reaching Python — the cheapest option and the least controllable, since it depends on what the component exposes. In the callback, as a timestamp comparison in session state, it prevents the expensive work but not the rerun, so the script still executes on every event and everything cheap in it runs ninety times. In the query layer, as a quantised cache key, it does not prevent anything but makes the repeats free, which composes with either of the others. Most dashboards want the callback guard plus the quantised key: together they bound both the work and the query count without depending on component internals.THREE PLACES A DEBOUNCE CAN LIVEin the browser — the component's own throttlecheapest, because the event never reaches Python — but limited to what the component exposesin the callback — a timestamp guard in session statestops the expensive work; the rerun still happens, so everything cheap in the script still runs ninety timesin the query layer — a quantised cache keyprevents nothing and makes the repeats free, which is why it composes with either of the aboveMost dashboards want the second and third together: the work is bounded, the query count is bounded, and neitherdepends on the internals of a component you do not control.

The distinction matters because the most common implementation — a timestamp guard in the callback — does not stop the rerun. Streamlit re-executes the whole script whenever the component returns a new value, and no amount of guarding inside the script prevents that. What the guard prevents is the expensive part, which is the point; but if the script itself is slow for other reasons, a debounce will not fix it.

Jump to heading Step 2 — Guard on a timestamp, and keep the newest bounds

python
import time
import streamlit as st

DEBOUNCE_SECONDS = 0.3

def viewport_settled(fresh: tuple) -> bool:
    """True when the map has stopped moving long enough to be worth querying."""
    st.session_state.pending_bbox = fresh            # always keep the newest
    now = time.monotonic()
    last = st.session_state.get("last_query_at", 0.0)
    if now - last < DEBOUNCE_SECONDS:
        return False
    st.session_state.last_query_at = now
    return True

Storing pending_bbox on every call is what makes this a debounce rather than a throttle. A leading-edge throttle fires on the first event and discards the rest, which for a pan means querying the extent the map had when the drag started — the wrong answer, delivered promptly. Keeping the newest value means the query, whenever it runs, uses where the map actually is.

Jump to heading Step 3 — Wire it into the render path

python
result = st_folium(build_map(layer, st.session_state.bbox),
                   height=560, returned_objects=["bounds"])

if result and result.get("bounds"):
    b = result["bounds"]
    fresh = (round(b["_southWest"]["lng"], 4), round(b["_southWest"]["lat"], 4),
             round(b["_northEast"]["lng"], 4), round(b["_northEast"]["lat"], 4))

    if fresh != st.session_state.bbox and viewport_settled(fresh):
        st.session_state.bbox = st.session_state.pending_bbox
        st.rerun()

subset = query_viewport(st.session_state.bbox)      # cached; see step 4
st.caption(f"{len(subset):,} features in view")

The two guards compose in a specific order: the equality check first, because it is free and removes the events that changed nothing at all, then the debounce, which is only asked about events that represent real movement.

Jump to heading Step 4 — Make the survivors cheap

Rounding the bounding box to four decimals before it becomes a cache key means that the handful of events that do survive the debounce during a slow pan mostly land on the same key, so they hit rather than re-query. This is the quantised cache key idea applied at the other end of the same problem.

python
@st.cache_data(ttl=600, max_entries=32, show_spinner=False)
def query_viewport(bbox: tuple) -> "gpd.GeoDataFrame":
    return filter_by_bounds(load_layer(), *bbox)

The debounce bounds how many queries are issued; the quantised key bounds how many of those are actually computed. Neither alone is sufficient on a fast pan.

Jump to heading Step 5 — Choose the interval against the query, not against a habit

What the threshold costs and buysDebounce intervals are plotted against the two things they trade. A fifty millisecond window feels instantaneous and barely reduces the query count, because pointer events arrive faster than that. Two hundred milliseconds removes most of the redundant queries and is still below the threshold at which a person perceives delay. Three hundred is the sweet spot for a spatial query that takes a few hundred milliseconds itself, since the debounce is hidden inside latency the user was going to experience anyway. Five hundred begins to feel sticky on a fast query and is still worth it on a slow one. A full second is perceptible as lag regardless, and is only defensible when the underlying query is so expensive that issuing two of them is worse than waiting.DEBOUNCE INTERVAL — PERCEIVED DELAY AGAINST QUERIES SAVED50 msfeels instant · saves almost nothing200 msbelow the perception threshold · most of the saving300 mshidden inside a query that takes that long anyway500 mssticky on a fast query,fine on a slow one1000 msperceptible as lagwhatever the query costsPick it against the query's own latency, not against a number from a blog post: a debounce shorter than the round tripit protects is decoration, and one much longer is a delay the user attributes to the map.

Jump to heading Verification

python
# 1. The window suppresses, and the newest bounds survive it.
st.session_state.last_query_at = time.monotonic()
assert viewport_settled((-0.2, 51.4, -0.1, 51.5)) is False, "fired inside the window"
assert st.session_state.pending_bbox == (-0.2, 51.4, -0.1, 51.5), "newest bounds lost"

# 2. After the window it fires, with the latest value rather than the first.
time.sleep(DEBOUNCE_SECONDS + 0.05)
assert viewport_settled((-0.3, 51.3, -0.05, 51.6)) is True
assert st.session_state.pending_bbox == (-0.3, 51.3, -0.05, 51.6)

Then measure it in the browser. Open the network panel, pan for two seconds, and count the requests: the target is one or two, not ninety. Counting is more reliable than watching, because a dashboard that issues ninety fast queries can look acceptable on a quiet local database and fall over on production data.

Jump to heading Edge cases and gotchas

  • time.time() versus time.monotonic(). Use the monotonic clock. A wall-clock jump from an NTP correction can make the guard think a query ran in the future and suppress every subsequent event until the clock catches up.
  • The debounce state is per session. Storing last_query_at in a module global rather than in session state makes two concurrent analysts debounce each other, so one person’s panning suppresses another person’s queries.
  • Very slow queries need a different tool. If the query takes several seconds, no debounce interval is comfortable — the fix is to make the query fast, usually with a spatial index or a coarser simplification tier, and to debounce afterwards.
  • A trailing-only debounce feels unresponsive. Nothing happens for the whole window after the gesture ends. Adding a cheap immediate acknowledgement — a spinner, a dimmed layer, a caption that says “updating” — costs nothing and removes the impression of lag entirely.

Jump to heading FAQ

Is throttling the same as debouncing here?

No, and the difference matters for a viewport. A throttle fires at a fixed maximum rate, so during a two-second pan at a 300 ms throttle you issue six queries, five of which are for extents the map has already left. A debounce fires once the events stop, so you issue one, for the extent the map settled on. For a viewport you almost always want the debounce.

Can I debounce inside the component instead?

If the component exposes it, yes, and it is the cheapest option because the event never crosses into Python at all. The reason not to rely on it alone is that you do not control the component’s behaviour across versions, and a debounce that quietly stops working after an upgrade fails in exactly the way that is hardest to notice.

Why does the script still run on every event?

Because Streamlit reruns whenever a component returns a new value, and the guard lives inside the script rather than above it. That is acceptable as long as everything expensive in the script is behind the guard or behind a cache — and it is a good reason to keep the top of a page cheap, since it is executed far more often than it appears.

Back to Widget Lifecycle Management.