Here is an example I vibe coded that works even across server restarts. However it would be great if this is an option that panel serve could support without the gymnastics required here
Phase 0: proof-of-concept for cookie-based Panel session persistence.
Two-layer approach
------------------
Layer 1 — live object reuse (server still running):
A persistent UUID cookie ('dvue_user_id') is set on first visit.
panel.config.reuse_sessions + session_key_func map that UUID to the
existing live Bokeh Document, so the browser reconnects to the same
Python object. No widget state is lost.
Layer 2 — param restore (after server restart):
On every selection change the selection is saved to a tiny JSON file
keyed by the UUID. On a fresh session pn.state.onload reads the
cookie, loads the JSON, and restores the selection (and re-draws the
plot) before the page is shown.
Usage
-----
Run programmatically (required — `panel serve` cannot patch the Tornado
handler before the server starts):
cd dvue
python examples/ex_url_state_reset.py
Then open http://localhost:5007 in your browser.
What to verify
--------------
1. First visit: page loads; cookie 'dvue_user_id' appears in DevTools →
Application → Cookies. Session ID shown in the banner.
2. Select rows, click Plot. Plot appears.
3. Close the browser tab. Reopen http://localhost:5007 (no query string).
→ Same Session ID shown. Selection AND plot are both restored.
(Layer 1 — live object reused.)
4. Stop the server (Ctrl-C), restart it, reopen the URL.
→ New Session ID. Selection is restored from JSON; plot is re-drawn.
(Layer 2 — param restore from disk.)
5. Open a second browser (or private window). It gets a different UUID
and a completely independent session.
Depends only on: panel, holoviews, numpy, pandas
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from uuid import uuid4
import numpy as np
import pandas as pd
import panel as pn
import holoviews as hv
# ---------------------------------------------------------------------------
# Layer 1 — Custom Tornado handler: set UUID cookie, enable session reuse
# ---------------------------------------------------------------------------
# Must happen BEFORE pn.serve() / BokehServer.__init__() — this is why we
# cannot use `panel serve script.py` for this demo.
from bokeh.server.urls import per_app_patterns
from panel.config import config
from panel.io.server import DocHandler
class _SessionAwareDocHandler(DocHandler):
"""Injects a persistent 'dvue_user_id' UUID cookie on first visit.
The cookie is injected into self.request.cookies (not just the response)
so that session_key_func sees it on the very first request — before
state._sessions has been populated for this user.
"""
_COOKIE_NAME = "dvue_user_id"
async def get(self, *args, **kwargs):
user_id = self.get_cookie(self._COOKIE_NAME)
if not user_id:
user_id = uuid4().hex
# Set response cookie (persists in the browser across restarts)
self.set_cookie(self._COOKIE_NAME, user_id, expires_days=365, path="/")
# Inject into the current request's cookie jar so session_key_func
# can read it immediately (SimpleCookie.__setitem__ with a plain
# string creates a Morsel with .value == user_id).
self.request.cookies[self._COOKIE_NAME] = user_id
await super().get(*args, **kwargs)
# Replace Bokeh/Panel's default per-app doc handler with our custom one.
per_app_patterns[0] = (r"/?", _SessionAwareDocHandler)
# Tell Panel to reuse an existing session when the UUID cookie matches.
config.reuse_sessions = True
config.session_key_func = lambda r: (
r.cookies["dvue_user_id"].value
if "dvue_user_id" in r.cookies
else r.path # fallback for requests without the cookie
)
# ---------------------------------------------------------------------------
# Layer 2 — JSON state store: persist selection across server restarts
# ---------------------------------------------------------------------------
_STORE_DIR = Path(tempfile.gettempdir()) / "dvue_session_state"
_STORE_DIR.mkdir(parents=True, exist_ok=True)
def _load_state(user_id: str) -> dict:
path = _STORE_DIR / f"{user_id}.json"
try:
return json.loads(path.read_text())
except Exception:
return {}
def _save_state(user_id: str, state: dict) -> None:
path = _STORE_DIR / f"{user_id}.json"
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(state))
tmp.replace(path) # atomic on POSIX; near-atomic on Windows
# ---------------------------------------------------------------------------
# pn.extension — must happen before any widget is created
# ---------------------------------------------------------------------------
pn.extension("tabulator", sizing_mode="stretch_width")
hv.extension("bokeh")
# ---------------------------------------------------------------------------
# Sample data
# ---------------------------------------------------------------------------
_t = pd.date_range("2020-01-01", periods=365, freq="D")
SERIES = {
"sine_fast": pd.Series(np.sin(2 * np.pi * _t.dayofyear / 30), index=_t, name="value"),
"sine_slow": pd.Series(np.sin(2 * np.pi * _t.dayofyear / 180), index=_t, name="value"),
"cosine": pd.Series(np.cos(2 * np.pi * _t.dayofyear / 90), index=_t, name="value"),
"trend": pd.Series(np.linspace(-1, 1, len(_t)), index=_t, name="value"),
}
catalog_df = pd.DataFrame({
"name": list(SERIES.keys()),
"description": [
"Fast sine (30-day period)",
"Slow sine (180-day period)",
"Cosine (90-day period)",
"Linear trend (-1 to 1)",
],
})
# ---------------------------------------------------------------------------
# Widgets — created once per session (either new or reused)
# ---------------------------------------------------------------------------
table = pn.widgets.Tabulator(
catalog_df,
selectable=True,
show_index=False,
disabled=True,
height=200,
)
plot_button = pn.widgets.Button(name="Plot", button_type="primary", width=100)
plot_pane = pn.pane.HoloViews(
hv.Curve([]).opts(width=750, height=300, title="(press Plot)"),
sizing_mode="stretch_width",
)
info_pane = pn.pane.Markdown("_Loading..._")
# ---------------------------------------------------------------------------
# Plot helper — draw curves for selected rows
# ---------------------------------------------------------------------------
def _draw_plot(selection: list[int]) -> None:
if not selection:
plot_pane.object = hv.Curve([]).opts(
width=750, height=300, title="(no selection)"
)
return
curves = []
for i in selection:
name = catalog_df.iloc[i]["name"]
s = SERIES[name]
df = s.reset_index()
df.columns = ["datetime", "value"]
curves.append(hv.Curve(df, "datetime", "value", label=name))
plot_pane.object = hv.Overlay(curves).opts(
hv.opts.Curve(width=750, height=300, tools=["hover"]),
hv.opts.Overlay(legend_position="top_right", title="Selected series"),
)
def _on_plot(event):
_draw_plot(table.selection)
plot_button.on_click(_on_plot)
# ---------------------------------------------------------------------------
# onload: restore state (Layer 2) and wire up save-on-change
# ---------------------------------------------------------------------------
def _on_load():
# --- Identify user ---
user_id = pn.state.cookies.get("dvue_user_id", "(unknown)")
try:
sid = pn.state.curdoc.session_context.id
is_reused = getattr(pn.state.curdoc.session_context, "_reused", False)
except Exception:
sid = "(unavailable)"
is_reused = False
layer = "Layer 1 — live object reused" if is_reused else "Layer 2 — new session"
info_pane.object = (
f"**User UUID:** `{user_id}`\n\n"
f"**Session ID:** `{sid}`\n\n"
f"**Persistence:** {layer}"
)
# --- Layer 2: restore from JSON (no-op if session was reused via Layer 1) ---
# When the session is reused (Layer 1), widgets already carry their previous
# state — no need to touch them. We only restore from JSON in a fresh session.
if not is_reused and user_id != "(unknown)":
saved = _load_state(user_id)
sel = saved.get("selection", [])
if sel:
table.selection = sel
_draw_plot(sel)
# --- Wire up save-on-change ---
def _save(event):
uid = pn.state.cookies.get("dvue_user_id", "")
if uid:
_save_state(uid, {"selection": table.selection or []})
table.param.watch(_save, "selection")
pn.state.onload(_on_load)
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
pn.Column(
pn.pane.Markdown("""
## Cookie-based session persistence — Phase 0 demo
**How to exercise Layer 1 (live object reuse):**
1. Select rows and click **Plot**.
2. Close this browser tab.
3. Reopen `http://localhost:5007` — the Session ID stays the same and the
plot is still there (same Python object served).
**How to exercise Layer 2 (param restore after restart):**
1. Select rows and click **Plot**.
2. Stop the server (`Ctrl-C`) and restart it (`python examples/ex_url_state_reset.py`).
3. Reopen `http://localhost:5007` — new Session ID, but selection and plot
are restored from the JSON store on disk.
"""),
info_pane,
table,
plot_button,
plot_pane,
).servable(title="Session Persistence Demo")
# ---------------------------------------------------------------------------
# Programmatic launch — only runs when executed directly, not when Panel
# imports this file to build the app inside an existing server.
# ---------------------------------------------------------------------------
if __name__ == "__main__":
pn.serve(
{"": __file__}, # serve this script at /
port=5007,
show=True,
# Keep sessions alive for 30 days (milliseconds).
unused_session_lifetime_milliseconds=2_592_000_000,
)