Mitigating Cloud Run Cold Starts for Streamlit Spatial Dashboards
Keep at least one instance warm, lazy-import the geospatial stack, and defer every GeoDataFrame load behind @st.cache_data so a cold Streamlit container never makes a real user wait through a multi-second import-and-load sequence.
Jump to heading Why this matters
A cold start on Cloud Run means a brand-new container: the Python runtime boots, your module-level import geopandas drags in shapely, pyproj, and the GDAL/PROJ bindings, and only then does your dashboard begin loading its first GeoDataFrame. For a spatial app that easily totals five to fifteen seconds before the first byte reaches the browser — long enough that users assume the tool is broken. This is the sharpest edge of the request-scoped serverless model, and it is entirely avoidable. The fixes below attack the cold start from three directions at once: keep a warm instance so most requests never cold-start, shrink what the cold path actually does, and move the data load off the import path.
Jump to heading Prerequisites
- A containerized Streamlit dashboard already deployable to Cloud Run — see the parent Cloud Run & Serverless Deployment workflow for the base
gcloud run deploycommand. - Python 3.11+,
streamlit>=1.32,geopandas>=0.14,rasterio>=1.3,pyproj>=3.6. - The
gcloudCLI authenticated to your project.
Jump to heading Step-by-step solution
Jump to heading Step 1 — Set a minimum instance count
The single highest-leverage change is keeping one instance warm. A warm instance has already booted the runtime, imported the geospatial stack, and (with pre-warming) loaded its data, so requests it serves never cold-start:
gcloud run services update spatial-dashboard \
--region=europe-west1 \
--min-instances=1
Set --min-instances to your steady-state concurrent-session count divided by your per-instance concurrency. Twelve regular users at a concurrency of 6 means --min-instances=2.
Jump to heading Step 2 — Lazy-import heavy geo libraries
A module-level import geopandas runs on every cold start whether or not the current request needs it. Move heavy imports into the functions that actually use them so a cold container only pays for the code paths it hits:
import streamlit as st
# NO module-level `import geopandas` / `import rasterio` here.
def load_boundaries(region_slug: str):
import geopandas as gpd # imported the first time this runs, not at boot
gdf = gpd.read_parquet(f"data/{region_slug}.parquet")
return gdf.to_crs("EPSG:4326")
def load_raster(path: str):
import rasterio # only paid for if a raster layer is requested
with rasterio.open(path) as src:
return src.read(1)
The imports are cached by Python after first use, so the cost is paid once per process rather than repeatedly.
Jump to heading Step 3 — Defer GeoDataFrame loads behind a cache
Never load a GeoDataFrame at module scope — that puts the read, parse, and reprojection directly on the cold-start import path. Wrap it in @st.cache_data so it runs once per instance and every rerun after that is a cache hit:
import streamlit as st
@st.cache_data(ttl=3600, show_spinner="Loading spatial layer…")
def get_boundaries(region_slug: str, crs: str = "EPSG:4326"):
import geopandas as gpd
gdf = gpd.read_parquet(f"data/{region_slug}.parquet")
return gdf.to_crs(crs)
# Called inside the page body — the heavy work happens here, once, cached.
gdf = get_boundaries("uk_lad_2023")
st.write(f"{len(gdf)} boundaries loaded")
This mirrors the deterministic-key guidance in Query Result Caching: pass only scalar arguments so the cache key stays stable across reruns.
Jump to heading Step 4 — Enable startup CPU boost
Startup CPU boost grants extra CPU during the container’s startup window, which directly shortens whatever import and load work still runs on a cold path:
gcloud run services update spatial-dashboard \
--region=europe-west1 \
--cpu-boost \
--cpu=2
Jump to heading Step 5 — Pre-warm with a startup hook
Combine a warm instance with a startup hook so the default viewport’s data is loaded before the first user arrives. Use @st.cache_resource to guarantee the hook fires once per instance, and a background thread so startup does not block:
import threading
import streamlit as st
@st.cache_resource
def prewarm() -> bool:
def _load_defaults() -> None:
# Populate the same @st.cache_data entries a real request would hit.
get_boundaries("uk_lad_2023", "EPSG:4326")
get_boundaries("uk_lad_2023", "EPSG:3857") # web-tile CRS
threading.Thread(target=_load_defaults, daemon=True).start()
return True
prewarm() # runs once per instance, at first script execution
For the background thread to keep making progress between requests, the service must run with always-allocated CPU (--no-cpu-throttling) as described in the parent page.
Jump to heading Verification
Time two consecutive requests to confirm the warm path is fast and that min-instances is keeping the cold path away from real users:
import time
import urllib.request
URL = "https://spatial-dashboard-xxxx.a.run.app/_stcore/health"
def timed_get(url: str) -> float:
start = time.perf_counter()
with urllib.request.urlopen(url, timeout=30) as resp:
resp.read()
return (time.perf_counter() - start) * 1000
first = timed_get(URL)
second = timed_get(URL)
print(f"first={first:.0f}ms second={second:.0f}ms")
# With min-instances>=1 and pre-warming, the first hit should already be warm.
assert second < 400, "Warm request slow — check CPU allocation and cache hits"
A correctly warmed service returns both requests in well under half a second. If the first request is multiple seconds while the second is fast, an instance was still cold-starting — raise --min-instances or confirm pre-warming actually populated the cache.
Jump to heading Edge cases and gotchas
- Module-level import cost hides in transitive dependencies. Even if you lazy-import
geopandas, a helper module you import at the top ofapp.pymight import it eagerly, dragging the cost back onto the boot path. Audit your import graph — a strayfrom utils import *whereutilsimportsgeopandasundoes Step 2. - Session affinity interacts with warm instances. A warm instance only helps a returning user if their session is routed back to it. Keep
--session-affinityon, and design the dashboard to rebuild session state from the cache if the user does land on a different (possibly cold) instance. - Scale-to-zero is a real cost tradeoff, not a mistake.
--min-instances=0genuinely saves money for dashboards used a handful of times a day. Choose it deliberately when a five-to-ten second first load is acceptable, and reserve--min-instances=1for tools where cold starts would reach real users mid-session.
Jump to heading FAQ
How much does a module-level geopandas import add to cold start?
Importing geopandas pulls in shapely, pyproj, and the underlying GDAL and PROJ bindings, which commonly adds one to three seconds before your first line of application code runs. On a cold container that time is added directly to the first user’s wait. Moving the import inside the functions that use it removes it from the import path for any request that does not touch that code.
Does min-instances remove cold starts completely?
It removes them for as many concurrent sessions as your warm instances can hold at your configured concurrency. Traffic that exceeds that capacity still spins up new cold instances. Set --min-instances to cover your steady-state concurrent sessions divided by concurrency, and accept that bursts above that line will still see an occasional cold start.
Is scale-to-zero ever worth the cold-start cost?
For internal dashboards used a few times a day, scale-to-zero with --min-instances=0 saves real money and a five-to-ten second first load is usually tolerable. For anything customer-facing or continuously used, the always-warm cost of --min-instances=1 is small next to the user experience of never paying a cold start.
Back to Cloud Run & Serverless Deployment
Related
- Cloud Run & Serverless Deployment — the full deployment workflow these mitigations plug into
- Streaming Large GeoJSON Responses from Cloud Run — sibling technique for large-payload responses on the same platform
- Async Data Loading Patterns — non-blocking prefetch that pairs with a warmed instance
- Query Result Caching — deterministic cache keys for the deferred data loads above