Time-Travel Debugging
Scrub back through event history and jump to any past view state (beyond Redux DevTools)
On this page
New in v0.6.1. Forward replay and branched timelines added in v0.9.0. Dev-only. Every @event_handler dispatch records a
snapshot of the view's public state before and after the handler
runs. From the browser debug panel you can scrub back through the
history, jump to any past state, and replay forward from that point — with optional branching so you can try alternate handler params without losing the original timeline. Think Redux DevTools, but for Django LiveViews and with zero client-side state store.
Recording is enabled per view with time_travel_enabled = True;
jumping, replay and the debug panel additionally require DEBUG=True.
When the opt-in is off, the event dispatch path runs without
instrumentation. Recording itself is not gated on DEBUG (see
Limitations), so don't ship the attribute switched on.
Quick start
from djust import LiveView
from djust.decorators import event_handler
class CounterView(LiveView):
template = "<div>{{ count }}</div>"
# Opt into time-travel debugging for this view.
time_travel_enabled = True
def mount(self, request, **kwargs):
self.count = 0
@event_handler
def increment(self, **kwargs):
self.count += 1
@event_handler
def reset(self, **kwargs):
self.count = 0
- Open your app in a browser with
DEBUG=True. - Click a few times to fire the
incrementhandler. - Press
Ctrl+Shift+D(Cmd+Shift+Don macOS) to open the debug panel, then switch to the Time Travel tab. - Click any past event in the timeline — the server restores the
captured
state_before(orstate_after), and the page re-renders instantly.
How it works
Time-travel adds a per-view-instance ring buffer of
EventSnapshot entries. Each snapshot captures:
event_name— the handler that firedparams— the event payloadref— the client-assigned monotonic refts— server timestate_before— snapshot of public attributes BEFORE the handlerstate_after— snapshot AFTER the handlererror— truncated error message if the handler raised
Capture reuses the same _capture_snapshot_state() filter that backs
the v0.6.0 state-snapshot feature: public, JSON-serializable public
attributes only. Private (_-prefixed) attributes and non-JSON
objects are filtered out automatically. The buffer is bounded
(default 100 events, override via
LIVEVIEW_CONFIG["time_travel_max_events"]) and thread-safe.
Restoration uses safe_setattr from djust.security, so dunder keys
(__class__, etc.) and unsafe names are refused even if the buffer
is tampered with.
The jump flow over the WebSocket looks like this:
Client Server
------ ------
time_travel_jump → handle_time_travel_jump
{index: 3, which: "before"}
restore_snapshot(view, snap)
render_with_diff()
← patch / html update
← time_travel_state
{cursor: 3, which: "before",
history_len: 42}
Config
| Setting | Default | Effect |
|---|---|---|
time_travel_enabled | False | Global breadcrumb (see system check djust.C501). Views still opt in via the class attribute. |
time_travel_max_events | 100 | Per-view ring-buffer cap. Validated by djust.C502. |
Set via LIVEVIEW_CONFIG in settings.py:
LIVEVIEW_CONFIG = {
"time_travel_enabled": True,
"time_travel_max_events": 50,
}
Limitations
- Side effects do not replay. Restoring state rolls back in-memory attributes only. Any SQL writes / external API calls fired during the original handler are not undone. This is a debugging aid, not a transaction system.
- Private attributes are not recorded. The snapshot filter skips
_-prefixed names. Put debug-worthy state in public attributes. - Non-JSON values are silently skipped. Store primitives / dicts / lists in public attributes. ORM instances should be stored as serialized dicts or fetched by PK inside the handler.
- Jumping is dev only; recording is not.
DEBUG=Falsesilently disables the jump and replay receivers at the consumer layer and stops events being pushed to the panel. But a view withtime_travel_enabled = Truestill allocates the buffer and records state snapshots (including any PII in public attributes) on every event in production. Don't ship the attribute enabled, or tie it to the setting:time_travel_enabled = settings.DEBUG.
Forward replay
Jumping to a past event restores state_before for that event. To see
what happens after the handler runs with different parameters, use
replay_event() from the debug panel or programmatically:
from djust.time_travel import replay_event
# Replay event at index 3 from its state_before snapshot, but with
# different params. The original entries are preserved; the replay is
# recorded as a new entry.
replay_event(
view,
snapshot=snapshots[3],
override_params={"user_id": 42},
record_replay=True, # capture new state_after on this branch
)
override_params is optional — omit it to replay the same event with
identical parameters, which is useful for re-running a handler that had
a network timeout or side-effect failure.
record_replay=True (the default) appends a new entry for the replay to
the same ring buffer, leaving the original event's snapshot untouched.
record_replay=False runs a dry replay that changes the view without
recording it. Calling replay_event() from Python does not allocate a
branch id; branch ids come from replays started in the debug panel.
Branches in the debug panel
In the debug panel's Time Travel tab, each row has a ⏵ replay
button. Replaying from an entry that isn't the latest switches the
tab's branch badge from main to a new branch-N id. The panel shows
this single badge, not a tree of branches.
Branches are in-memory only — they disappear on page refresh or
server restart, and there is no API to save one. To capture a branch for
a regression test, reproduce it with LiveViewTestClient (send_event
with the override params); see Testing.
Per-component snapshots
v0.9.0 extends the ring buffer to capture per-component public state alongside the parent LiveView's state. A multi-component page (e.g. dashboard with a chart component, a data table, and an activity feed) can scrub back through history with each component's state faithfully restored.
Component snapshots are enabled automatically when
time_travel_enabled = True on the parent view — no per-component
opt-in required. In the debug panel, rows that captured component state
get a "▶ N comp" toggle that expands per-component ↶ restore buttons.
Comparison
| djust Time Travel | Redux DevTools | Phoenix LiveView debug | |
|---|---|---|---|
| Scrub past events | ✓ | ✓ | (telemetry only) |
| Restore state + re-render | ✓ | ✓ (reducer replay) | ✗ |
| State diff before / after | ✓ | ✓ | ✗ |
| Client-side state store needed | ✗ (server holds it) | ✓ (entire store) | N/A |
| Works with server-side rendering | ✓ | ✗ | ✓ |
| Forward replay / branching | ✓ (v0.9.0) | ✓ | ✗ |
| Per-component snapshots | ✓ (v0.9.0) | ✓ (per-slice) | ✗ |
Security notes
- Jumping and replay are DEBUG-gated at the WebSocket consumer. A
production client cannot coerce the server into restoring state by
sending
time_travel_jumpframes. Recording is not DEBUG-gated: an opted-in view records snapshots in production too. - Restoration uses
safe_setattr, matching the v0.6.0 state-snapshot hardening — dunder keys and anything failing theSAFE_ATTRIBUTE_PATTERNregex are rejected. - Snapshots are held in process memory only. There is no persistence; a dev-server restart clears the buffer.
- Jump-during-async race: if you jump to a past snapshot while an
async task (from
@backgroundorstart_async()) is still running, the async task's eventual completion will overwrite the restored state. This is expected behavior — djust does not cancel in-flight tasks on jump. Best practice: wait for any loading indicators to clear before using time-travel. - Container mutations safely captured: starting v0.6.1,
_capture_snapshot_statedeep-copies state via a JSON round-trip, soself.items.append(...)orself.metrics["k"] = vinside a handler does not retroactively corrupt prior snapshots. Earlier v0.6.0enable_state_snapshotusers benefit from this fix too — the same method backs both features. - Per-frame size cap:
time_travel_eventframes are capped at 16 KiB (one quarter of the conventional 64 KiB WebSocket frame limit). Oversized snapshots (e.g. from a view with a 1000-row list) have theirstate_before/state_afterreplaced with{"_truncated": True, "_size": N}before the frame is sent, and the entry carries"_truncated": trueat the top level. The full state is still available server-side and is restored correctly ontime_travel_jump.
Do NOT enable
time_travel_enabledon views holding PII or secrets. The buffer stores up to 100 full snapshots of public view state. If your view contains passwords, tokens, session IDs, SSNs, credit card numbers, or similar sensitive fields, opt out — those values will sit in memory in a dev-panel-readable form for the lifetime of the process. This mirrors the guidance forenable_state_snapshotand the v0.6.0 state-snapshot feature.
Async / background caveats
state_after is captured synchronously at the moment the handler
returns control to the dispatcher:
- Work scheduled via
start_async()or wrapped in@backgroundruns in a thread after the handler returns. Any state it mutates will appear in the next event's snapshot (or not at all, if no further event fires). async defhandlers are fully awaited beforestate_afteris captured, so awaited coroutines are reflected correctly. Only fire-and-forget background work is deferred out of the snapshot.
If you need to time-travel past the result of background work, mutate a public flag in the background callback and trigger a follow-up event to capture the final state.
Related
- Developer Tools — the debug panel and its tabs.
- Hot View Replacement — state-preserving Python reload in dev (v0.6.1 sibling feature).
- LiveView API reference — the underlying
_capture_snapshot_statefilter reused here.