Optimizing app performance

Streamlit reruns your script from top to bottom whenever a user interacts with a widget. This makes apps simple to write, but it also means slow code runs again on every interaction unless you tell Streamlit otherwise. This guide brings together the techniques that make apps fast and keep them responsive while slow work is unavoidable.

  1. Cache expensive data loading and computation. Cache source data, then apply cheap filters outside the cached function.
  2. Use fragments so independent sections rerun on their own, and run slow, independent fragments in parallel.
  3. Use forms or on_change="ignore" to avoid reruns for inputs that don't need an immediate reaction.
  4. Don't run expensive work inside hidden tabs, collapsed expanders, or closed popovers.
  5. Render fast UI before slow work, and use st.skeleton to reserve space for slow elements.
  6. Use client-side validation so the browser catches bad input without a rerun.

Use st.cache_data for functions that return data, like dataframes and API responses. Use st.cache_resource for shared resources, like database clients and machine learning models. Don't wrap st.connection in a cache decorator because it's already cached.

Cache the expensive source data, and apply interactive filters outside the cached function. If you cache the filtered result instead, Streamlit fetches the data again for every new filter value and stores a separate result for each one.

Python
# Avoid: fetches the data again for every new region
@st.cache_data
def load_filtered_orders(region):
    orders = fetch_orders()
    return orders[orders["region"] == region]

region = st.selectbox("Region", regions)
orders = load_filtered_orders(region)
Python
# Prefer: cache the expensive load once and filter outside the cache
@st.cache_data(ttl="15m")
def load_orders():
    return fetch_orders()

region = st.selectbox("Region", regions)
orders = load_orders()
filtered_orders = orders[orders["region"] == region]

Caches are unbounded by default. Use ttl so data doesn't become stale, and use max_entries when you know how many distinct inputs your function receives. For data that can be slightly out of date, use refresh_mode="background" so users get the expired value immediately while Streamlit refreshes it. For more information, see Caching overview.

A fragment reruns by itself when a user interacts with a widget inside it. Use fragments for sections that don't affect the rest of the page, like a chart with its own controls or a live search.

Python
@st.fragment
def live_search():
    query = st.text_input("Search", type="search", live=True)
    st.dataframe(search(query))

live_search()

To refresh a section on a timer, use run_every instead of looping your script. Never use while True or time.sleep() followed by st.rerun(), because they block the script or rerun the whole app.

Python
@st.fragment(run_every="30s")
def live_metrics():
    st.metric("Active users", get_active_user_count())

live_metrics()

By default, fragments run one after another. If your page has several slow sections that don't depend on each other, like separate database queries, set parallel=True so they run concurrently during full-app reruns. Three sections that each take three seconds then load in about three seconds instead of nine.

Python
@st.fragment(parallel=True)
def revenue():
    st.metric("Revenue", query_revenue())

@st.fragment(parallel=True)
def users():
    st.metric("Users", query_users())

revenue()
users()

Parallel fragments run in threads, so they help most with I/O-bound work like queries and API calls. Each fragment should write to its own Session State keys. For more information and restrictions, see Run fragments in parallel.

If several inputs feed one expensive operation, put them in a form. The app reruns only when the user submits the form.

Python
with st.form("search", border=False):
    query = st.text_input("Search")
    category = st.selectbox("Category", categories)
    submitted = st.form_submit_button("Search", icon=":material/search:")

if submitted:
    results = search(query, category)

Forms don't work when some inputs need to update others, like a country selection that changes the available cities. In that case, set on_change="ignore" on the inputs that don't affect anything else. These widgets update in the browser without a rerun, and Python receives their values on the next rerun. Values that the browser holds this way are lost if the user refreshes the page, unless the widget also uses key and bind="query-params".

Python
country = st.selectbox("Country", countries)  # Reruns to update the city options
city = st.selectbox("City", get_cities(country), on_change="ignore")
name = st.text_input("Name", on_change="ignore")

if st.button("Save"):
    save_contact(country, city, name)

Use on_change="ignore" only for widgets where the user doesn't expect an immediate reaction. Any rerun delivers all pending values, so gate the work that uses them behind a button. For more information, see Widget behavior.

By default, Streamlit runs the code inside every tab, expander, and popover on every rerun, even when the content isn't visible. If that content is expensive, set on_change="rerun" on the container. Then, its .open attribute reports whether it's visible, and you can skip the work when it isn't.

Python
overview, details = st.tabs(["Overview", "Details"], on_change="rerun")

if overview.open:
    with overview:
        show_overview()
if details.open:
    with details:
        show_expensive_details()  # Runs only when this tab is selected
Python
diagnostics = st.expander("Advanced diagnostics", on_change="rerun")
if diagnostics.open:
    with diagnostics:
        run_diagnostics()  # Runs only when the expander is open

Alternatively, use st.segmented_control or st.toggle with an if statement. For more information, see Using layouts and containers.

Streamlit sends elements to the browser as your script creates them. During a rerun, elements from the previous run stay on screen and fade as stale until the new run recreates them. If slow code runs before the rest of your page, users see a faded page until it finishes, even for elements that don't depend on the slow result.

Order your script so that titles, filters, section headers, and anything else that doesn't depend on slow work render first. Then, wrap the slow work and the element it feeds in st.skeleton. The skeleton reserves the element's space with an animated placeholder while the work runs.

Python
# Avoid: the whole page waits for the slow load
orders = load_orders()  # Takes several seconds
st.title("Orders")
region = st.selectbox("Region", regions)
st.dataframe(orders)
Python
# Prefer: the title and filter render immediately
st.title("Orders")
region = st.selectbox("Region", regions)

with st.skeleton(height=400):
    orders = load_orders()
    st.dataframe(orders, height=400, key="orders")  # Same height as the skeleton

To keep the layout stable:

  • Give the skeleton the same height as the element it holds.
  • Put the slow element inside the with block, and draw it first. Any visible element inside the block replaces the skeleton.
  • Give stateful elements, like dataframes and charts, a key. On reruns, the previous element stays in place and updates when the new data arrives, so it keeps its scroll position and selections. The skeleton only covers it if the block takes longer than a short delay.

Avoid filling an st.empty() placeholder after slow work. An empty placeholder clears its content as soon as the script reaches the st.empty() call, so the old element disappears during the slow work and loses its state.

For a dashboard with several slow cards, combine parallel fragments with skeletons. Draw each card's frame and title in the main script, and call its fragment inside a skeleton. The page layout appears immediately, and each card fills in as soon as its data arrives.

Python
@st.fragment(parallel=True)
def revenue_card():
    st.bar_chart(load_revenue(), x="region", y="revenue", height=260, alt="Revenue by region")

@st.fragment(parallel=True)
def orders_card():
    st.dataframe(load_orders(), height=260, hide_index=True, alt="Recent orders")

left, right = st.columns(2)
with left.container(border=True):
    st.subheader("Revenue by region")
    with st.skeleton(height=260):
        revenue_card()
with right.container(border=True):
    st.subheader("Recent orders")
    with st.skeleton(height=260):
        orders_card()

When a wait is unavoidable, show the user that something is happening:

  • st.skeleton reserves space for charts, tables, and metrics while they load. Prefer it for content with a known size.
  • st.spinner shows a lightweight indicator while a block of code runs.
  • st.progress shows a progress bar when you know how much work is done.
  • st.status shows progress and intermediate output for long, multi-step tasks.

Many widgets can reject invalid input before it reaches Python. This avoids a rerun and gives users immediate feedback. Use these parameters when your widget supports them:

  • st.text_input: validate, required, max_chars, and type. type="email" and type="url" check the format.
  • st.text_area: required and max_chars.
  • st.number_input: min_value, max_value, step, and required.
  • st.date_input and st.datetime_input: min_value, max_value, and required.
  • st.selectbox and st.time_input: required.
  • st.file_uploader: type, which rejects files with other extensions.
Python
email = st.text_input("Email", type="email", required=True)
quantity = st.number_input("Quantity", min_value=1, max_value=99, step=1)

These checks improve the user experience, but they aren't a security boundary. Always validate input in Python before you use it for anything sensitive.

Everything you display is sent to the browser. Large payloads slow down your app even when your Python code is fast.

  • Charts: Aggregate data before charting it. From about 10,000 points per chart, rendering in the browser takes longer than your Python code. For more information, see Chart large datasets.
  • Selection widgets: Keep option lists in the low thousands. Instead of loading a whole table to build a dropdown, query distinct values with a limit. For larger domains, use a search input with live=True and query matching values.
  • Precomputation: Move heavy aggregations into your database, a scheduled job, or a materialized view instead of computing them in your app on demand.
forum

Still have questions?

Our forums are full of helpful information and Streamlit experts.