Elm's Time-Travel Debugging: What It Is and Where It Went
Elm's architecture makes every UI state change replayable—but the famous time-travel debugger was removed in 0.19. Here's how to trace and replay bugs today.
27 Dec 2025, 00:20 UTC

The bug you can't reproduce
Anyone maintaining a content-heavy UI—a blog feed with category filters, live search, and expandable comment threads—eventually hits the same class of bug: a transient state error that only appears after a specific sequence of clicks. You add console.log calls, reload, click around, and the bug vanishes. The problem isn't your logging; it's that the application's state is scattered, so there is no single history to inspect.
Elm's answer is architectural. The Elm Architecture (TEA) forces every state change through one pure update function: a Msg plus the current Model produces the next Model. Because state is centralized and transitions are deterministic, the entire session is a replayable sequence of messages. That property is what makes time-travel debugging possible at all.
An honest caveat: check your Elm version
Here is the part many older tutorials skip. The famous time-travel debugger shipped with elm-reactor in Elm 0.18. In Elm 0.19 (the current stable line), the built-in debugger was removed, and elm reactor no longer offers message replay. If you are starting a project today, you will not get time-travel debugging out of the box.
Your practical options in 0.19 are:
- Log messages yourself. Wrap
updatein a function that passes everyMsgandModelthroughDebug.logduring development. Becauseupdateis pure, the logged history is a faithful record. - Replay in tests. Feed a recorded list of messages through
updatewithelm-testorelm-program-testto reproduce a sequence deterministically—this is time-travel debugging done manually, and it works in CI. - Pin a legacy prototype to 0.18 only if you specifically need the old reactor debugger for exploration; do not ship new production code on it.
Claims about a current built-in debugger or a browser extension for Elm 0.19 should be treated as unverified—confirm against the release notes for your exact version before relying on them.
Worked example: tracing a filter bug by hand
Suppose your blog feed empties when a user picks "Technology" and then "Design". The relevant code:
type alias Model =
{ currentFilter : Category
, posts : List Post
}
type Msg
= SelectCategory Category
| ClearFilter
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SelectCategory cat ->
( { model | currentFilter = cat }, Cmd.none )
ClearFilter ->
( { model | currentFilter = All }, Cmd.none )
Add a tracing wrapper in development:
updateTraced : Msg -> Model -> ( Model, Cmd Msg )
updateTraced msg model =
let
_ = Debug.log "msg" msg
_ = Debug.log "filter" model.currentFilter
in
update msg model
Run elm make src/Main.elm --output=main.js (from your project root, with the Elm 0.19 compiler installed) and reload the page. In the browser console you will see every message in order. If ClearFilter appears immediately after SelectCategory—for instance, because a port message from JavaScript fires on the same click—you have found the exact transition that wipes the feed, without guessing. Remove the wrapper before building with --optimize; in fact, elm make --optimize refuses to compile code containing Debug.log, which gives you a built-in safety check.
Replaying the sequence as a test
Once you know the message sequence, lock it in:
test "selecting two categories keeps posts visible" =
let
msgs = [ SelectCategory Technology, SelectCategory Design ]
finalModel =
List.foldl (\msg ( m, _ ) -> update msg m) initialModel msgs
|> Tuple.first
in
Expect.notEqual [] (visiblePosts finalModel)
Run this with elm-test from the project root. Because update is pure, this replay is exact—no browser, no timing, no flakiness. This is the durable benefit of TEA: even without a graphical time-travel tool, the architecture makes any UI bug reproducible as data.
Limitations
Replay only captures what flows through update. Side effects handled via ports or subscriptions—analytics calls, localStorage, scroll position—are not part of the Model, so a replayed sequence can diverge from real browser behavior. Keep as much state as possible inside the Model if you want faithful reproduction. Also note that Debug.log output is not structured; for large models, log only the fields you care about.
What to do next
Confirm your compiler version with elm --version. If you are on 0.19, adopt the trace-then-test workflow above: wrap update to capture the message history, reproduce the failing sequence, then encode it as an elm-test case so the bug stays fixed. You lose the retro charm of the 0.18 debugger, but you gain something better—a regression test that runs on every commit.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.