Home Features Docs Blog Philosophy Examples FAQ Hosting
Documentation

Time-Travel Debugging

Scrub back through event history and jump to any past view state (beyond Redux DevTools)

Full documentation is on docs.djust.org

This page is a lightweight reference. The complete guide — with tutorials, theming, code examples, and more — lives on our dedicated docs site.

View on docs.djust.org

Time-Travel Debugging

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.

Gated on DEBUG=True and per-view opt-in. Zero cost in production — when the opt-in is off, the event dispatch path runs without instrumentation.

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
  1. Open your app in a browser with DEBUG=True.
  2. Click a few times to fire the increment handler.
  3. Press Ctrl+D (or click the debug-bar icon) to open the debug panel, then switch to the Time Travel tab.
  4. Click any past event in the timeline — the server restores the captured state_before (or state_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 fired
  • params — the event payload
  • ref — the client-assigned monotonic ref
  • ts — server time
  • state_before — snapshot of public attributes BEFORE the handler
  • state_after — snapshot AFTER the handler
  • error — 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.
  • Dev only. DEBUG=False silently disables the jump receiver at the consumer layer. The class attribute is still safe to leave on in shared codebases — production just won't allocate the buffer because the consumer rejects jumps before touching it.

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. Opens a branched timeline — the original history
# is preserved.
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 writes the new state_after into a branch of the ring buffer, leaving the original event's snapshot untouched. You can open multiple branches from the same snapshot by calling replay_event() with different override_params.

Branches and the branched timeline panel

The debug panel's Time Travel tab shows branches as a tree. Each branch is labeled with the override_params that produced it. Clicking any branch node jumps the view to that snapshot.

Branches are in-memory only — they disappear on page refresh or server restart. To persist a branch for a regression test, use time_travel.save_fixture() (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. The debug panel shows a component selector dropdown when the page has more than one LiveComponent.

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

  • Both recording and jumping are DEBUG-gated at the WebSocket consumer. A production client cannot coerce the server into restoring state by sending time_travel_jump frames.
  • Restoration uses safe_setattr, matching the v0.6.0 state-snapshot hardening — dunder keys and anything failing the SAFE_ATTRIBUTE_PATTERN regex 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 @background or start_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_state deep-copies state via a JSON round-trip, so self.items.append(...) or self.metrics["k"] = v inside a handler does not retroactively corrupt prior snapshots. Earlier v0.6.0 enable_state_snapshot users benefit from this fix too — the same method backs both features.
  • Per-frame size cap: time_travel_event frames 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 their state_before / state_after replaced with {"_truncated": True, "_size": N} before the frame is sent, and the entry carries "_truncated": true at the top level. The full state is still available server-side and is restored correctly on time_travel_jump.

Do NOT enable time_travel_enabled on 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 for enable_state_snapshot and 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 @background runs 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 def handlers are fully awaited before state_after is 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.