---
title: "Time-Travel Debugging"
slug: time-travel-debugging
section: guides
order: 32
level: intermediate
description: "Scrub back through event history and jump to any past view state (beyond Redux DevTools)"
---

# 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.

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](#limitations)), so don't ship the attribute switched on.

## Quick start

```python
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+Shift+D` (`Cmd+Shift+D` on macOS) 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`:

```python
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=False` silently
  disables the jump and replay receivers at the consumer layer and stops
  events being pushed to the panel. But a view with
  `time_travel_enabled = True` still 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:

```python
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](testing.md).

### 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_jump` frames. **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 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.

## Related

- [Developer Tools](developer-tools.md) — the debug panel and its tabs.
- [Hot View Replacement](hot-view-replacement.md) — state-preserving
  Python reload in dev (v0.6.1 sibling feature).
- [LiveView API reference](../api-reference/liveview.md) — the underlying
  `_capture_snapshot_state` filter reused here.
