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.

What the import graph costs before the port is even boundA stacked bar of a cold container's four point six second import phase, segmented by what each top-level import pulls in. Streamlit itself is about four hundred milliseconds. The geopandas import accounts for two point six seconds, most of it in its transitive dependencies: pyproj reading the PROJ database, shapely loading GEOS, fiona and the GDAL bindings. Rasterio adds a further one point five seconds and is needed only on pages that render a raster layer. Below, the same boot after moving geopandas and rasterio into the functions that use them: only Streamlit is imported at boot, so the port binds in about four hundred milliseconds, and each heavy import is paid once, later, and only by a request that actually needs it.COLD BOOT — TIME BEFORE THE PORT IS BOUNDmodule-level imports0.4 simport geopandas — 2.6 simport rasterio — 1.5 sstreamlitpyproj reads the PROJ database · shapely loads GEOS · fiona and the GDAL bindingsneeded only by pages that render a raster4.6 sfunction-level imports0.4 sport bound — the startup probe is satisfied heregeopandas is imported inside load_boundaries, on the first request that needs a vector layer; rasterio inside load_raster,on the first request that needs a raster — and on many instances, never.

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.

Who pays for the first loadFour lifelines: the platform, the container's first script execution, the background warm-up thread, and the first analyst. Because min-instances is one, the platform starts a container with no request waiting on it. The first script execution runs the cache_resource hook, which returns immediately after spawning a daemon thread, so nothing blocks. The thread populates the same cache entries a real request would hit — the default region in the geographic system and again in the web-tile system — while no user is connected. When the first analyst finally arrives, both lookups are hits and the response contains no load at all. The point of the diagram is where the cost lands: it is paid by a container the platform started for its own reasons, not by the first person to open the dashboard.WHO PAYS FOR THE FIRST LOADCloud Runfirst script runwarm-up threadfirst analyststart container — min-instances=1, no request waitingprewarm() spawns a daemon thread and returnsget_boundaries(..., "EPSG:4326")get_boundaries(..., "EPSG:3857")the same cache entries a real request hitsfirst page view — both lookups hitThe load did not get cheaper — it moved onto a container the platform started for its own reasons, several secondsbefore anybody opened the dashboard.

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