UI state resets on browser refresh

I have never understood why the session id established by the browser doesn’t allow to persist UI states between refreshes. Is this possible in panel serve apps?

I have a complex UI and use location hashs which restores widget state but any holoviz plots in tabbed panels etc get reset on browser refresh.

Any solutions to this?

Can you provide an example? But I suspect a GitHub issue would be good too!

It is probably a misunderstanding on my part so I am not sure a github issue is needed just yet.
See my minimal example below. You can select rows on the table and click Plot button.
The session id is displayed above the Plot.
Then pressing F5 or reload on browser may select the rows but certainly the Plot is gone and the session id is a new one.

"""
Minimal demonstration of Panel UI state reset on page reload.

Depends only on: panel, holoviews, numpy, pandas

Run with:
    panel serve examples/ex_url_state_reset.py --show --port 5007

Select rows, click Plot, then reload — the selection is restored but the plot is gone.
"""

import numpy as np
import pandas as pd
import panel as pn
import holoviews as hv

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
# ---------------------------------------------------------------------------
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)"))

session_id_pane = pn.pane.Markdown("_Session ID: (loading...)_")

# ---------------------------------------------------------------------------
# Plot callback (triggered only by the button)
# ---------------------------------------------------------------------------
def _on_plot(event):
    selection = table.selection
    if not selection:
        plot_pane.object = hv.Text(0.5, 0.5, "No rows selected").opts(width=750, height=300)
        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"),
    )

plot_button.on_click(_on_plot)

# ---------------------------------------------------------------------------
# URL sync setup
# ---------------------------------------------------------------------------
def _setup_url_sync():
    loc = pn.state.location
    if loc is None:
        return

    # Display session ID to prove each reload is a new session
    try:
        sid = pn.state.curdoc.session_context.id
    except Exception:
        sid = "(unavailable)"
    session_id_pane.object = f"**Session ID:** `{sid}`  _(changes on every reload)_"

    # Restore selection from ?sel=0,1
    raw = (loc.query_params or {}).get("sel", "")
    if raw:
        try:
            table.selection = [int(x) for x in raw.split(",") if x.strip().isdigit()]
        except Exception:
            pass

    # Write selection back to URL whenever it changes
    def _write_selection(event):
        sel = table.selection or []
        loc.update_query(sel=",".join(str(i) for i in sel) if sel else None)

    table.param.watch(_write_selection, "selection")


pn.state.onload(_setup_url_sync)

# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
pn.Column(
    pn.pane.Markdown("""
## URL state reset demo

1. Select rows in the table and click **Plot**.
2. Note the `?sel=` param added to the URL.
3. Reload the page — selection is restored from the URL, but **the plot is gone**
   because Panel starts a new Python session on every reload and the button was
   never clicked in the new session.
"""),
    table,
    plot_button,
    session_id_pane,
    plot_pane,
).servable(title="URL State Reset Demo")

Run the above example by saving as a file and panel serve file.

I also got reminded that is not the first time encountering this issue. I marked this github issue on discourse last time as well.

Multi-page session cleanup · Issue #8189 · holoviz/panel

In addition, i gave a possible solution though I don’t know how to implement it. See my comment there Multi-page session cleanup · Issue #8189 · holoviz/panel · GitHub

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,
    )

Yeah I’m not sure; I think an issue would be best! I also pinged Discourse.

@ahuang11 I have been able to adapt the approach above to have the UI persist through reloads and revisits to the same url with the cookie approach above. I guess it is solved but not natively supported via panel serve. I am ok with that as I have switched to using pn.serve instead of panel serve

I could open an issue as a feature request.