Configuring Sticky Sessions for Stateful Spatial Dashboards on Kubernetes
Pin each browser to the pod holding its state with nginx Ingress affinity: cookie and raise the proxy WebSocket timeouts — because Streamlit and Panel keep the map viewport, filters, and cached GeoDataFrames in per-pod memory, so a request routed to any other pod loses the session.
Jump to heading Why this matters
Streamlit and Panel are stateful servers wearing a web-app costume. When a user opens a dashboard, the framework builds a session object in the memory of one specific pod — the current map bounds, the selected CRS, the sidebar filter values, and often a cached multi-megabyte GeoDataFrame scoped to that session. None of that lives in a shared store by default. Put three replicas behind a plain Kubernetes Service and the round-robin load balancer sends the user’s next interaction to a pod that has never seen them: the map snaps back to its initial extent, or the WebSocket reconnect lands on the wrong pod and the dashboard hangs. Sticky sessions are therefore not an optimisation for these frameworks; they are a correctness requirement, and they interact tightly with the autoscaling described in Kubernetes autoscaling & orchestration and the state model in Session State Patterns.
Jump to heading Prerequisites
- A dashboard deployed on Kubernetes with
ingress-nginxterminating traffic, following Kubernetes autoscaling & orchestration. - Kubernetes 1.27+ and an
ingress-nginxcontroller (cookie affinity and WebSocket upgrade support are built in). - A Streamlit (
8501) or Panel (5006) Service already resolving to your pods.
Jump to heading Step-by-step solution
Jump to heading Step 1 — Enable cookie affinity on the nginx Ingress
Cookie affinity is the correct primitive because it identifies each browser, not each source IP. On the first response the controller sets a route cookie; on every subsequent request it reads that cookie and forwards to the same pod. Add the annotations to the Ingress:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: spatial-dashboard
annotations:
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/affinity-mode: "persistent"
nginx.ingress.kubernetes.io/session-cookie-name: "spatial_route"
nginx.ingress.kubernetes.io/session-cookie-max-age: "3600"
nginx.ingress.kubernetes.io/session-cookie-samesite: "Lax"
spec:
ingressClassName: nginx
rules:
- host: dashboard.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: spatial-dashboard
port:
number: 80
affinity-mode: persistent keeps a user bound to their pod even when the backend endpoint list changes during a scale event — the default balanced mode may rebalance cookies when pods are added, which is exactly the reshuffle you want to avoid for stateful sessions. The max-age should comfortably exceed a typical working session.
Jump to heading Step 2 — Preserve the WebSocket upgrade
Sticky routing is useless if the long-lived WebSocket is cut. Streamlit and Panel push every interaction over a single WebSocket that stays open for the whole session, but nginx closes idle proxied connections after 60 seconds by default and will drop a socket that sits quiet while a user reads a map. Raise the timeouts:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
ingress-nginx passes the Upgrade and Connection headers automatically once HTTP/1.1 is in use, so no manual header configuration is needed — but confirm no upstream proxy or cloud load balancer in front of the ingress strips them. A quick check from the browser devtools network tab: the /_stcore/stream request (Streamlit) or /ws request (Panel) should show status 101 Switching Protocols, not 200 or 400.
Jump to heading Step 3 — Weigh the Service ClientIP alternative
If you cannot terminate an HTTP-aware ingress — for example a bare LoadBalancer Service in front of the pods — Kubernetes offers a coarser fallback: sessionAffinity: ClientIP, which routes by source IP.
apiVersion: v1
kind: Service
metadata:
name: spatial-dashboard
spec:
selector:
app: spatial-dashboard
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800 # 3h affinity window
ports:
- port: 80
targetPort: 8501
Understand the trade-off before reaching for it. ClientIP keys on the source address the Service sees, so every user behind a shared corporate NAT, VPN egress, or cloud load balancer that hides the real client IP collapses onto a single pod, destroying load balancing exactly when you have many users. It also cannot distinguish two browser tabs from the same machine. Prefer cookie affinity whenever an ingress is in the path; treat ClientIP as a last resort for direct-Service topologies.
Jump to heading Step 4 — Reconcile affinity with scale-down
Affinity guarantees a user reaches their pod — it does nothing if the autoscaler deletes that pod. Close the gap with the graceful-termination trio so a pinned pod finishes its sessions before exiting:
spec:
terminationGracePeriodSeconds: 90
containers:
- name: dashboard
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 60"]
With this in place, when the HPA (see tuning HPA for bursty spatial dashboard sessions) scales down and selects a pod, the preStop sleep lets its bound WebSocket sessions wind down before SIGTERM arrives, and the HPA’s own scale-down stabilization window ensures the removal was warranted in the first place.
Jump to heading Verification
Confirm the route cookie is issued and honoured:
# first request sets the affinity cookie
curl -s -I https://dashboard.example.com/ | grep -i set-cookie
# set-cookie: spatial_route=1a2b3c...; Path=/; HttpOnly; Max-Age=3600; SameSite=Lax
# reuse the cookie and confirm the same backend responds each time
COOKIE="spatial_route=1a2b3c..."
for i in 1 2 3; do
curl -s -H "Cookie: $COOKIE" https://dashboard.example.com/_stcore/health -w "%{http_code} " -o /dev/null
done
# 200 200 200 — all served, session-consistent
Then verify the WebSocket upgrade end to end: open the dashboard, interact with a filter, wait two minutes idle (past nginx’s old 60-second default), and interact again. The map must respond without a reconnect flash, proving both the raised read timeout and the affinity are holding.
Jump to heading Edge cases and gotchas
- ClientIP breaks behind shared NAT: a whole office behind one egress IP pins to a single pod under
sessionAffinity: ClientIP, so nine of ten pods sit idle while one is overloaded. This is the primary reason to prefer cookie affinity — it identifies the browser, not the network. - Affinity versus scale-down: a sticky cookie will keep sending a user to a pod that the autoscaler is about to terminate. Without the
preStopdrain and grace period from Step 4, affinity actively concentrates soon-to-be-dropped traffic. Affinity and graceful termination must be configured together, never one without the other. - Affinity timeout expiry mid-session: if
session-cookie-max-ageor theClientIP timeoutSecondsis shorter than a real working session, the binding lapses and the next request is rebalanced to an empty pod. Set the affinity window longer than your longest expected session (three hours is a safe ceiling for analyst dashboards) and treat state loss on timeout as a signal the window is too short.
Jump to heading FAQ
Why do Streamlit and Panel dashboards need sticky sessions on Kubernetes?
Streamlit and Panel keep each user’s session state — map viewport, filter selections, cached GeoDataFrames — in the memory of the specific pod that first served them, not in a shared store. Without affinity, the Service load-balances the next request to a different pod that has none of that state, so the map resets or the WebSocket fails to reconnect. Cookie affinity on the Ingress pins the browser to its pod so the state stays reachable for the whole session.
Should I use Ingress cookie affinity or Service sessionAffinity ClientIP?
Prefer Ingress cookie affinity. It identifies each browser individually with a cookie, so two users behind the same corporate NAT are still pinned to different pods and load balancing works. Service sessionAffinity: ClientIP keys on source IP, which collapses every user behind a shared NAT or egress load balancer onto one pod. Use ClientIP only when you cannot terminate an HTTP-aware ingress in front of the pods.
Does session affinity keep working during autoscaling scale-down?
Affinity keeps a user on their pod, but it cannot save a session if the autoscaler terminates that pod. Pair affinity with a preStop drain hook and a terminationGracePeriodSeconds long enough for open sessions to finish, plus an HPA scaleDown stabilization window. Together they ensure a pinned pod is only removed after its sessions wind down rather than mid-map.
Back to Kubernetes Autoscaling & Orchestration
Related
- Tuning Kubernetes HPA for Bursty Spatial Dashboard Sessions — the scaling side of the same problem, where affinity and graceful drains keep scale-down from dropping pinned users
- Kubernetes Autoscaling & Orchestration — the full orchestration workflow this affinity configuration plugs into
- Session State Patterns — why Streamlit and Panel hold per-session state in the pod, the root reason affinity is required