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.

Where each cold-start mitigation acts on the startup sequenceThe startup sequence runs left to right: runtime boot, then importing geopandas rasterio and pyproj, then loading the GeoDataFrame, then serving. Three labelled mitigations point at the phases they remove or shrink: min-instances keeps an instance warm so the entire sequence is skipped for most requests, lazy imports shrink the import phase, and deferring the data load behind st.cache_data takes the GeoDataFrame load off the cold import path.Cold container startup sequenceruntime bootimport geopandas / rasterioload GeoDataFrameservemin-instances = 1keeps an instance warm — most requests skip the whole sequencelazy importsshrink the import phase — unused code paths cost nothing@st.cache_datadefers the data load — runs once per instance, not on importstartup CPU boost compresses the import and load phases wherever they still run

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 deploy command.
  • Python 3.11+, streamlit>=1.32, geopandas>=0.14, rasterio>=1.3, pyproj>=3.6.
  • The gcloud CLI 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:

bash
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:

python
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:

python
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:

bash
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:

python
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:

python
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 of app.py might import it eagerly, dragging the cost back onto the boot path. Audit your import graph — a stray from utils import * where utils imports geopandas undoes 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-affinity on, 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=0 genuinely 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=1 for 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