lib/streamlit/.agents/skills/developing-with-streamlit/references/server-asgi.md
Use st.App when a normal Streamlit script needs ASGI-level composition: custom HTTP routes, middleware, startup/shutdown hooks, programmatic secrets, custom exception handling, or mounting with another ASGI framework.
Do not create an ASGI wrapper for a simple app that only needs Streamlit UI and ordinary Streamlit configuration. When the app does need advanced server features, keep the UI in a normal Streamlit script and launch the st.App wrapper with streamlit run asgi_app.py when possible.
Use st.App for:
st.secretsDo not use it just to set ports, headless mode, theme options, or regular Streamlit config. Use .streamlit/config.toml or streamlit run flags for those.
Keep the Streamlit UI in a normal script, then create a separate ASGI wrapper.
# streamlit_app.py
import streamlit as st
st.title("Dashboard")
st.write("This is the normal Streamlit script.")
# asgi_app.py
import streamlit as st
app = st.App("streamlit_app.py")
Run with Streamlit's CLI when possible:
streamlit run asgi_app.py
streamlit run detects the ASGI app instance and runs it with Streamlit's uvicorn integration. You can also run it with an ASGI server directly:
uvicorn asgi_app:app --host 0.0.0.0 --port 8501
st.App is the official import path for the ASGI entry point.
App.run()For shareable ASGI launchers that should run with python app.py, uv run app.py,
or uvx --with streamlit python app.py, call App.run() under a main guard in
the wrapper:
# app.py
import streamlit as st
app = st.App("streamlit_app.py")
if __name__ == "__main__":
app.run()
Use app.run(config={...}) for programmatic config overrides that would otherwise be
passed as streamlit run flags:
if __name__ == "__main__":
app.run(config={"server.port": 8502, "server.address": "0.0.0.0"})
This pairs well with inline script dependencies when launched with uv run app.py:
the wrapper can declare streamlit and any launcher-only dependencies in a PEP 723
script metadata block at the top of the file.
# /// script
# dependencies = [
# "streamlit",
# ]
# ///
import streamlit as st
app = st.App("streamlit_app.py")
if __name__ == "__main__":
app.run()
Use this pattern only for launcher modules such as app = st.App("streamlit_app.py").
Avoid same-file launchers like app = st.App(__file__): Streamlit executes app scripts
in a fake __main__ module, so an if __name__ == "__main__": app.run() block inside
the Streamlit script can run again during app execution.
The script_path argument points to the Streamlit UI script, not the ASGI wrapper.
Relative paths are resolved differently depending on how the app starts:
streamlit run asgi_app.py, relative paths resolve from the script passed to streamlit run.App.run() direct launchers (python asgi_app.py, uv run asgi_app.py,
or uvx --with streamlit python asgi_app.py), relative paths resolve from the
launcher module.uvicorn asgi_app:app, relative paths resolve from the current working directory.Use an absolute path if the wrapper may be imported from different working directories.
Add Starlette routes when the app needs lightweight API endpoints.
import streamlit as st
from starlette.responses import JSONResponse
from starlette.routing import Route
async def health(request):
return JSONResponse({"status": "ok"})
async def data(request):
return JSONResponse({"items": ["apple", "banana", "cherry"]})
app = st.App(
"streamlit_app.py",
routes=[
Route("/api/health", health),
Route("/api/data", data),
],
)
User routes are added before Streamlit's internal routes, but they cannot conflict with reserved Streamlit route prefixes:
/_stcore//media//component//static/Prefer namespaced routes such as /api/... or /webhook/....
Add Starlette middleware for request/response behavior around the whole app.
import streamlit as st
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
return response
app = st.App(
"streamlit_app.py",
middleware=[Middleware(SecurityHeadersMiddleware)],
)
User middleware runs before Streamlit's internal middleware on requests and after it on responses.
Use lifespan hooks for process-level startup/shutdown work, not per-user session state. A common pattern is to preload shared cached resources or data before the first browser session connects.
# resources.py
import streamlit as st
@st.cache_resource
def get_database():
return connect_to_database()
@st.cache_data
def load_reference_data():
return fetch_reference_data()
# asgi_app.py
from contextlib import asynccontextmanager
import streamlit as st
@asynccontextmanager
async def lifespan(app):
# Import cached functions after st.App has started the Streamlit runtime.
from resources import get_database, load_reference_data
print("Starting Streamlit ASGI app...")
get_database() # Warm st.cache_resource.
load_reference_data() # Warm st.cache_data.
yield {"ready": True}
print("Shutting down Streamlit ASGI app...")
app = st.App("streamlit_app.py", lifespan=lifespan)
Put cached functions in a shared module when both the Streamlit script and the ASGI wrapper need to call them. For per-user state inside the Streamlit script, use st.session_state.
Import and call cached functions inside the lifespan hook rather than at launcher module
scope. The launcher module is imported before Streamlit starts its runtime, so a module-scope
@st.cache_data decoration is validated against a temporary in-memory storage manager and
logs a No runtime found warning (@st.cache_resource doesn't use that decoration-time
path). Either way, the real cache storage is only selected when a cached function is first
called, so warming inside the lifespan—after the runtime has started—uses the real cache and
avoids the warning.
If ASGI routes or middleware need process-level state that is not a Streamlit resource, the lifespan context manager may yield a dictionary. Those values are stored on app.state (app.state["ready"] in the example above).
refresh_mode="background" is the simplest way to avoid blocking on expiration when
slightly stale data is acceptable. It is access-driven, however: refresh starts only when a
call observes an expired entry, and that call receives the stale value.
For advanced cases that need the server to initiate refreshes even without user traffic, use
a lifespan task to periodically call the cached function with the arguments for each key that
should stay warm. Calls made while an entry is fresh are cheap cache hits. The first scheduled
call after its ttl returns the stale value to the task and triggers a deduplicated background
refresh for that specific key. Run synchronous cached functions in a worker thread (via
anyio, already a Streamlit dependency) so they don't block the ASGI event loop.
# resources.py
import streamlit as st
@st.cache_data(ttl="10m", refresh_mode="background")
def load_metrics(dataset):
return fetch_metrics(dataset)
# asgi_app.py
import logging
from contextlib import asynccontextmanager
import anyio
import streamlit as st
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app):
# The Streamlit runtime is available when the user lifespan starts.
from resources import load_metrics
async def touch_periodically():
while True:
await anyio.sleep(60)
try:
# Only touch the cache entry for load_metrics("dashboard").
await anyio.to_thread.run_sync(load_metrics, "dashboard")
except Exception:
logger.exception("Scheduled cache refresh failed")
# Warm this specific key before serving requests. Unlike the periodic loop,
# this initial warm isn't wrapped in try/except, so a failure aborts startup.
await anyio.to_thread.run_sync(load_metrics, "dashboard")
async with anyio.create_task_group() as task_group:
task_group.start_soon(touch_periodically)
try:
yield
finally:
task_group.cancel_scope.cancel()
app = st.App("streamlit_app.py", lifespan=lifespan)
This pattern works for global st.cache_data and st.cache_resource entries whose argument
combinations are known to the scheduler. Each argument combination is a separate cache key,
so touching load_metrics("dashboard") does not refresh or invalidate entries for other
datasets.
Keep these caveats in mind:
ttl. Background refresh serves the stale value for only one
extra ttl; past 2 × ttl the entry is hard-evicted and the next call (including a
scheduled touch) blocks on a foreground recompute. Keep the interval well under ttl (not
just under 2 × ttl) so several touches land in the [ttl, 2 × ttl) grace window, giving
the scheduler multiple chances to trigger a refresh before hard eviction if a touch is
delayed or a refresh runs long.Use Mount when Streamlit should own the root URL and another ASGI app should live under a subpath.
import streamlit as st
from fastapi import FastAPI
from starlette.routing import Mount
api = FastAPI()
@api.get("/health")
async def health():
return {"status": "ok"}
app = st.App(
"streamlit_app.py",
routes=[Mount("/api", app=api)],
)
With this structure, the Streamlit app is served at / and the FastAPI routes are served under /api.
When a parent ASGI framework owns the root app, pass Streamlit's lifespan to the parent so the Streamlit runtime starts and stops correctly.
import streamlit as st
from fastapi import FastAPI
streamlit_app = st.App("streamlit_app.py")
api = FastAPI(lifespan=streamlit_app.lifespan())
@api.get("/api/data")
async def get_data():
return {"data": [1, 2, 3]}
api.mount("/dashboard", streamlit_app)
Run the parent app with an ASGI server:
uvicorn asgi_app:api --host 0.0.0.0 --port 8501
Only call streamlit_app.lifespan() when a parent ASGI framework will manage the lifecycle. Do not call lifespan() and then serve that same App standalone.
Use secrets= to programmatically supply values that the Streamlit script should read through st.secrets.
import os
import streamlit as st
app = st.App(
"streamlit_app.py",
secrets={
"database": {
"host": os.environ["DB_HOST"],
"password": os.environ["DB_PASSWORD"],
}
},
)
Programmatic secrets are shallow-merged with file-based secrets, and programmatic values win at the top level.
Use exception_handlers for ASGI/network-layer exceptions from custom routes.
import streamlit as st
from starlette.responses import JSONResponse
from starlette.routing import Route
class ApiError(Exception):
pass
async def route_that_fails(request):
raise ApiError("bad request")
async def api_error_handler(request, exc):
return JSONResponse({"error": str(exc)}, status_code=400)
app = st.App(
"streamlit_app.py",
routes=[Route("/api/fail", route_that_fails)],
exception_handlers={ApiError: api_error_handler},
)
Use on_script_error for uncaught exceptions from the Streamlit script or widget callbacks.
import streamlit as st
def handle_script_error(exc):
st.error("Something went wrong.")
return True # Suppress the default exception display.
app = st.App("streamlit_app.py", on_script_error=handle_script_error)
Return True from on_script_error only when the handler shows its own user-facing error UI. Return False or None to let Streamlit show the normal exception display.
App instances with different script_path values in the same process is not supported.