Frozen Views

Frozen Views are an optimization feature in elm-pages. You can think of it like Html.Lazy on steroids.

Since all Elm functions are pure we have a guarantee that the same input will always result in the same output. Html.Lazy gives us tools to be lazy about building Html that utilize this fact.

-- Html.Lazy docs

Html.Lazy.lazy todaysDateView model.today
-- only re-render when input has changed

Elm's Html.Lazy avoids unnecessary re-renders.

elm-pages' View.freeze takes it a step further - it never renders your code on the client-side. In fact, it doesn't even bundle the rendering code or the Data fields it depends on! Instead, it does the work to render the HTML for Frozen Views before it ever hits the client-side (at build-time, or at server-render time for server-rendered routes). Frozen views also work with form actions — when a form is submitted, the server re-renders the frozen HTML with the updated app.data and app.action, and the client adopts the new HTML without needing any of the rendering code.

Usage#

To use Frozen Views, you wrap part of your view code (must be within a Route Module file) that doesn't depend on dynamic parameters like your model with a call to View.freeze:

type alias Data =
{ today : Date
-- ^ Used ONLY in frozen content, so it's rendered at build time
-- then eliminated from `content.dat`—never sent to the client!
, initialCount : Int
-- ^ Used in `init`, so it IS sent to the client in `content.dat`.
}
type alias Model =
{ counter : Int }
init : App Data ActionData RouteParams -> ( Model, Effect Msg )
init app =
( { counter = app.data.initialCount }
-- dynamic usage, so this is in the client bundle
, Effect.none
)
view :
App Data ActionData RouteParams
-> Model
-> View (PagesMsg Msg)
view app model =
{ title = "My Page"
, body =
[ -- FROZEN: This render code will never run on the client!
View.freeze
(h1 []
[ text
("Today's date: "
++ Date.toIsoString app.data.today
-- ^ This function and all its dependencies
-- are dead-code eliminated from the client!
)
]
)
-- DYNAMIC: Uses `model`
, div []
[ button [ onClick (PagesMsg.fromMsg Decrement) ] [ text "-" ]
, text (String.fromInt model.counter)
, button [ onClick (PagesMsg.fromMsg Increment) ] [ text "+" ]
]
]
}

The frozen part renders to HTML at build time (or server-render time for server-rendered routes—and yes, this optimization works for those too!). On the client, that HTML is adopted without re-rendering using a mechanism similar to Html.Lazy—Elm's virtual DOM simply leaves those sections of the view alone:

<!-- Initial page load: this HTML is already in the page.
The Elm virtual DOM adopts it—no re-rendering needed!
For SPA navigations, the frozen HTML is included
in the content.dat response. -->
<h1>Today's date: 2025-01-27</h1>

Mental Model: Server-Only vs Client Regions#

elm-pages treats certain sections of your Route Modules as Server-Only. Using static analysis, elm-pages keeps track of which fields in a Route Module's Data record are used in Client Regions. Any Data record fields that are unused in Client Regions will never be sent to the client.

That means for example if you have a large markdown String, you can have that in your Data but you don't need to pay the penalty of sending two different representations of that to the client (the markdown String and the HTML).

In addition to that, elm-pages erases Server-Only Regions of code when building your client-side JS bundle so that the Elm compiler can perform dead code elimination to drop all of the code and dependencies that become unused.

type alias Data =
{ title : String
-- ^ Used in BOTH frozen and client regions → sent to client
, rawMarkdown : String
-- ^ Used ONLY in frozen region → never sent to client!
, comments : List Comment
-- ^ Used in client region → sent to client
}
-- SERVER-ONLY REGION: `head` only runs at build/server-render time
-- and this code is not in the client-side JS bundle
head : App Data ActionData RouteParams -> List Head.Tag
head app =
Seo.summaryLarge
{ title = app.data.title
, description = app.data.rawMarkdown |> markdownToDescription
-- ^^^^^^^^^^^^^^^^^^^^
-- This usage doesn't count as "client usage"—
-- rawMarkdown is still excluded from `content.dat`!
}
view :
App Data ActionData RouteParams
-> Model
-> View (PagesMsg Msg)
view app model =
{ title = app.data.title
, body =
[ -- SERVER-ONLY REGION: This code only
-- runs at build/server-render time
View.freeze
(div [] [ h1 [] [ text app.data.title ]
, app.data.rawMarkdown
|> markdownRenderView
-- ^ The Markdown code and all its dependencies
-- are dead-code eliminated from the client bundle!
]
)
-- CLIENT REGION: This code runs on both server
-- (to pre-render the initial HTML response) and client
-- It is therefore included in the client bundle
, commentsView app.data.comments
]
}

🛜 = sent to client | 🗑️ = eliminated

What the client receivesWithout Frozen ViewsWith Frozen Views
Pre-rendered HTML🛜🛜
Re-executes render code🛜🗑️ (Eliminated, similar to what Html.Lazy does)
app.rawMarkdown in content.dat🛜🗑️ (No duplicate data representation, just HTML)
Markdown dependency in JS bundle🛜🗑️ (Dead code eliminated!)

Real-World Results#

On the elm-pages.com docs site, frozen views cut the bundle size roughly in half:

MetricBeforeWith Frozen ViewsSavings
Raw163.3 KB77.7 KB-85.5 KB (-52%)
Gzipped49.8 KB26.4 KB-23.3 KB (-47%)

Frozen Views with Form Actions#

Frozen views work with server-rendered routes and form actions. When a form is submitted, the server re-renders the page (including frozen views) with the new app.action data. The updated frozen HTML is sent back in the response and adopted by the client — all without needing any of the rendering code in the client bundle.

view :
App Data ActionData RouteParams
-> Model
-> View (PagesMsg Msg)
view app model =
{ title = "My Page"
, body =
[ -- This frozen view updates after form submission!
-- The server re-renders it with the new app.action value.
View.freeze (actionResultView app.action)
-- Forms themselves need event handlers, so they stay dynamic
, form []
[ input [ name "query" ] []
, button [] [ text "Submit" ]
]
]
}
actionResultView : Maybe ActionData -> Html Never
actionResultView maybeAction =
case maybeAction of
Just actionData ->
div [] [ text ("Result: " ++ actionData.message) ]
Nothing ->
text ""

Note that while app.action can be used inside View.freeze, forms themselves cannot be frozen because they require event handlers (which produce messages, making them incompatible with the Html Never type).

When to Use Frozen Views#

Use View.freeze for content that:

  • Doesn't need interactivity - No click handlers, no dynamic updates
  • Uses heavy dependencies - Markdown parsers, syntax highlighters, complex formatting
  • Comes from server data - Content from app.data or app.action that is rendered on the server

Is It Inefficient to Send a Lot of HTML?#

You may be wondering whether it's inefficient to send all this HTML over pre-rendered for your Frozen Views. Intuitively, it seems efficient to have JavaScript rendering logic that we can re-use. A couple things to consider:

  1. JavaScript is more expensive per byte on your browser's CPU cycles (JavaScript costs 3x more in processing power according to Alex Russell)
  2. Gzip is remarkably good at handling repetitive HTML syntax
  3. For a first page load, your elm-pages site is sending over pre-rendered HTML anyway. When you use a frozen view, the page just accepts those frozen sections of the view and doesn't need extra rendering, JS code, or Data record fields. For subsequent SPA navigations, you will need to send over the HTML for the next page's Frozen Views. However, note that we have now avoided sending view code for the entire application just to render a single page!

Setting Up Frozen Views#

To use frozen views, update your View.elm to export the required functions:

module View exposing (View, map, freeze, freezableToHtml, htmlToFreezable)
import Html exposing (Html)
type alias View msg =
{ title : String
, body : List (Html msg)
}
map : (msg1 -> msg2) -> View msg1 -> View msg2
map fn doc =
{ title = doc.title
, body = List.map (Html.map fn) doc.body
}
{-| The type of content that can be frozen. Must produce no messages (Never).
-}
type alias Freezable =
Html Never
{-| Convert Freezable content to plain Html for server-side rendering.
-}
freezableToHtml : Freezable -> Html Never
freezableToHtml =
identity
{-| Convert plain Html back to Freezable for client-side adoption.
-}
htmlToFreezable : Html Never -> Freezable
htmlToFreezable =
identity
{-| Freeze content so it's rendered at build time and adopted on the client.
Frozen content:
- Is rendered at build time (or server-render time) and included in the HTML
- Is adopted by the client without re-rendering
- Has its rendering code and dependencies eliminated from the client bundle (DCE)
The content must be `Html Never` (no event handlers allowed).
-}
freeze : Freezable -> Html msg
freeze content =
content
|> freezableToHtml
|> htmlToFreezable
|> Html.map never

Using with Html.Styled#

If you use elm-css with Html.Styled, update the conversion functions:

module View exposing (View, map, freeze, freezableToHtml, htmlToFreezable)
import Html
import Html.Styled exposing (Html)
type alias View msg =
{ title : String
, body : List (Html msg)
}
map : (msg1 -> msg2) -> View msg1 -> View msg2
map fn doc =
{ title = doc.title
, body = List.map (Html.Styled.map fn) doc.body
}
type alias Freezable =
Html Never
freezableToHtml : Freezable -> Html.Html Never
freezableToHtml =
Html.Styled.toUnstyled
htmlToFreezable : Html.Html Never -> Freezable
htmlToFreezable =
Html.Styled.fromUnstyled
freeze : Freezable -> Html msg
freeze content =
content
|> freezableToHtml
|> htmlToFreezable
|> Html.Styled.map never

Constraints#

Frozen content must be Html Never

This is enforced by the type system. Html Never means "HTML that can never produce a message"—no event handlers allowed.

-- This works
View.freeze (div [] [ text "Hello" ])
-- This is a compile error
View.freeze (button [ onClick MyMsg ] [ text "Click" ])
-- ^^^^^^^^^^^^^^
-- Html Msg is not Html Never

Frozen content cannot use model

Frozen content is rendered at build time (or server-render time), before any client-side state exists. You can use app.data and app.action (server-provided data) but not model (runtime state).

The simple rule: Keep model out of View.freeze for optimal bundle size.

If you reference model inside a View.freeze, elm-pages will gracefully de-optimize that freeze call—the code will still work correctly, but the rendering code won't be eliminated from the client bundle. This prevents a subtle bug where frozen content would show stale model values.

-- These work AND are optimized (DCE applied)
View.freeze (text app.data.title)
View.freeze (actionResultView app.action)
-- This works but is NOT optimized (no DCE, dependencies stay in bundle)
View.freeze (text model.searchQuery)

This includes values derived from model through let bindings or case expressions:

-- Also de-optimized: `userName` is derived from `model`
let
userName = model.user.name
in
View.freeze (text userName)
-- Also de-optimized: `user` comes from case on `model`
case model.maybeUser of
Just user ->
View.freeze (text user.name)
...

To verify your freeze calls are being optimized, you can inspect your bundle with elmjs-inspect dist/elm.js.opt and check whether the dependencies you expect to be eliminated are present.

The --strict Flag#

By default, if a View.freeze call is de-optimized (e.g. because it references model), elm-pages will show a warning but continue the build. You can use the --strict flag to make de-optimized freeze calls fail the build instead:

elm-pages build --strict

This is especially useful in CI pipelines to enforce that all View.freeze calls are fully optimized. If any freeze call can't be optimized, the build will fail with an error showing which call(s) are affected and why.

Without --strict, de-optimized freeze calls still work correctly — the rendering code just won't be eliminated from the client bundle for those specific calls.

Frozen views must be in Route Modules

Currently, View.freeze can only be called within Route Module view functions—not in Shared.elm or other helper modules. This is a temporary limitation for the initial release that will likely be removed in the future.