When to Use Html.Lazy in Elm: A Practical Decision Guide
Elm re-runs your whole view on every model change. This guide compares plain Html, Html.Keyed, and Html.Lazy, explains when memoization pays off, and shows a verifiable example.
11 Jun 2026, 10:29 UTC

The decision you're actually making
Every time your Elm model changes, your view function runs again and builds a fresh virtual DOM tree. For most apps this is fine — the diff is cheap and Elm is fast. But once a subtree gets expensive (hundreds of nodes, a chart, a long list with rich rows), recomputing it on every keystroke or timer tick starts to show up as dropped frames.
Elm gives you three tools for this situation: plain Html (the default), Html.Keyed (for reorderable lists), and Html.Lazy (memoized sub-views). They solve different problems, and picking the wrong one either does nothing or introduces subtle staleness bugs. This guide helps you pick, then shows a working example.
Comparing the three options
| Approach | What it does | Best for | Main risk |
|---|---|---|---|
Plain Html | Recomputes the whole subtree on every model change | Small or cheap views; the correct default | Wasted CPU on deep, frequently-updated trees |
Html.Keyed | Matches DOM nodes to list items by key, so reorders move existing nodes instead of recreating them | Lists that reorder, insert, or delete items | Still runs every row's view function each update; does not skip work |
Html.Lazy / lazy2… | Caches the rendered result and only recomputes when an argument changes (structural equality) | Expensive pure sub-views whose inputs rarely change | Stale UI if you mutate inputs or rely on un-compared data; memory grows with cached views |
Html.LazyWith (elm-community/html-extra) | Like Lazy but you supply the equality function | Records where structural equality is too strict (e.g. ignore a timestamp field) | A wrong equality function silently freezes part of your UI |
A common misconception: keyed nodes and lazy nodes are not interchangeable. Keying controls DOM reuse when order changes. Laziness controls view recomputation when the model changes. A sortable table of expensive rows may want both — keyed on the container, lazy on each row.
Rules of thumb for choosing
- Start with plain
Html. Only reach forLazyafter you can point at a specific slow subtree, ideally confirmed in the browser's performance panel. - The lazy view function must be pure in practice: its output must depend only on the arguments you pass. If it reads anything else (a closed-over value that changes), Elm's structural equality check won't see the change and you'll render stale content.
- Pass the smallest possible arguments.
lazy renderUser userre-renders when any field ofuserchanges;lazy2 renderAvatar name avatarUrlonly re-renders when those two fields change. - Avoid laziness on views whose inputs change constantly (e.g. anything receiving a ticking clock value) — you pay the comparison and caching overhead and get nothing back.
- Don't apply
Lazyeverywhere defensively. Each lazy node allocates a cache entry, and a blanket of them makes real regressions harder to spot.
Concrete implementation
Suppose a dashboard re-renders every second because of a clock, but it contains an expensive activity feed that only depends on the list of events. Wrap just the feed:
module Main exposing (..)
import Html exposing (Html, div, text)
import Html.Lazy exposing (lazy)
-- Expensive: renders hundreds of rows
activityFeed : List Event -> Html Msg
activityFeed events =
div [] (List.map renderEvent events)
view : Model -> Html Msg
view model =
div []
[ text ("Time: " ++ model.currentTime)
, lazy activityFeed model.events
]Now, when model.currentTime updates each second, Elm compares the new model.events with the cached argument, finds them structurally equal, and skips calling activityFeed entirely. The clock text still updates normally.
If Event is a record containing a field you want to ignore (say, a lastFetched timestamp that shouldn't trigger a re-render), structural equality is too strict. Use Html.LazyWith from elm-community/html-extra:
import Html.LazyWith exposing (lazyWith)
eventsEqual : List Event -> List Event -> Bool
eventsEqual a b =
List.map .id a == List.map .id b
-- in view:
lazyWith eventsEqual activityFeed model.eventsBe careful here: this equality ignores everything except IDs, so edits to an event's title will not re-render. Write the equality to compare exactly the fields the view actually displays.
How to verify it works
Assumptions: Elm 0.19.1, compiled with elm make or run via elm reactor. No special permissions are needed; everything below happens in your own project directory and browser.
- Add a temporary
Debug.log "feed rendered" eventscall insideactivityFeed(compile without--optimize, since the optimizer stripsDebug). - Trigger an update that changes only the clock. With
lazyin place, the log should not fire. Replacelazy activityFeed model.eventswith a plain call and repeat — the log now fires on every tick. - For a real measurement rather than a log line, record a trace in your browser's performance panel during a few seconds of ticking and compare scripting time between the two versions.
- Remove the
Debug.logbefore committing.
Limitations
Laziness only helps when the comparison is cheaper than the render — usually true, but a very large argument list can make structural equality itself noticeable. Cached views also consume memory proportional to how many distinct lazy nodes exist, so lazily wrapping every row of an unbounded list can grow memory without bound. Finally, memoization can mask a performance bug elsewhere: if the UI feels fast only because a subtree is frozen, confirm it actually updates when its data changes. The Debug.log check above doubles as that confirmation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.