Managing Side Effects in Elm: Practical Patterns with Cmd and Sub
Elm's Cmd and Sub system lets you keep pure logic separate from side effects. This post shows a concrete fetch‑then‑poll example, explains batching trade‑offs, and gives a checklist to verify the program works.
06 Oct 2025, 17:09 UTC

The Problem: Side Effects in a Pure Language
Elm forces every function to be pure, yet real applications need HTTP calls, timers, and WebSocket messages. The language solves this by separating what* happens (the update function) from how* it happens (the Cmd and Sub types). Understanding this boundary is the first practical engineering decision you make when structuring an Elm codebase.
Cmd: Declaring Asynchronous Work
A Cmd msg is a value that tells the runtime “perform this effect and eventually give me a msg.” Because the type parameter is part of the command, the compiler guarantees that every possible result is handled in update. The most common commands are Http.send, Task.perform, and Cmd.batch.
Batching Multiple Requests
Cmd.batch lets you fire several independent commands at once. Each command is type‑checked individually, but the batch itself is opaque – you only receive the messages they produce. This is useful for “fetch‑then‑poll” patterns, yet it hides which sub‑command failed unless you propagate errors explicitly.
fetchAll : Cmd Msg
fetchAll =
Cmd.batch
[ Http.send GotUsers (Http.get usersUrl userDecoder)
, Http.send GotPosts (Http.get postsUrl postDecoder)
]
Run this in a project root with elm make src/Main.elm --output=main.js (requires Elm 0.19+ installed). The compiler will reject any mismatch between the decoder and the Msg constructor.
Sub: Listening to External Streams
A Sub msg registers interest in an external event source – timers, WebSocket frames, keyboard input, etc. The runtime calls your subscriptions function on every model change, so you can start or stop listening based on the current state.
Timer Subscription Lifecycle
subscriptions : Model -> Sub Msg
subscriptions model =
case model.pollState of
Polling ->
Time.every (1000 * 60) Tick
Idle ->
Sub.none
When model.pollState switches to Idle, the subscription is automatically cancelled, preventing a memory leak. Forgetting to return Sub.none in the idle branch is a common source of leaked timers.
Worked Example: Fetch‑then‑Poll
Below is a minimal, self‑contained Elm 0.19 program that demonstrates the full cycle: an initial HTTP fetch, a batched follow‑up request, and a recurring timer that refreshes the data.
module Main exposing (..)
import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)
import Http
import Json.Decode as Decode
import Time
-- MODEL
type alias Model =
{ users : List String
, pollState : PollState
}
type PollState = Polling | Idle
init : (Model, Cmd Msg)
init =
( { users = [], pollState = Idle }
, fetchUsers
)
-- MESSAGES
type Msg
= GotUsers (Result Http.Error (List String))
| Tick
| TogglePoll
-- UPDATE
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
GotUsers (Ok names) ->
( { model | users = names, pollState = Polling }
, Cmd.none
)
GotUsers (Err _) ->
( model, Cmd.none )
Tick ->
( model, fetchUsers )
TogglePoll ->
let
newState =
if model.pollState == Polling then Idle else Polling
in
( { model | pollState = newState }, Cmd.none )
-- COMMANDS
fetchUsers : Cmd Msg
fetchUsers =
Http.send GotUsers
(Http.get "https://jsonplaceholder.typicode.com/users"
(Decode.list (Decode.field "name" Decode.string))
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
case model.pollState of
Polling ->
Time.every (1000 * 60) Tick
Idle ->
Sub.none
-- VIEW
view : Model -> Html Msg
view model =
div []
[ button [ onClick TogglePoll ]
[ text (if model.pollState == Polling then "Stop polling" else "Start polling") ]
, div [] (List.map (
name -> div [] [ text name ]
) model.users)
]
main : Program () Model Msg
main =
Browser.element
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
To verify the program works, run elm reactor in the project directory and open http://localhost:8000/src/Main.elm. You should see a list of user names appear, and the “Start polling” button toggles a one‑minute refresh. No runtime exceptions should appear in the browser console.
Trade‑off: Opacity of Cmd.batch
While Cmd.batch reduces boilerplate, it merges multiple independent failure paths into a single message stream. If one request fails, you only learn about it when its specific Msg arrives; the batch itself gives no aggregate error. A practical mitigation is to keep batches small (2‑3 commands) and give each command a distinct result message, as shown in the example above.
Checking the Result
- Compile with
elm make src/Main.elm --output=main.js– the compiler will surface any type mismatches between decoders andMsgconstructors. - Run
elm reactororelm-live src/Main.elm --opento serve the app. - Open the browser devtools console; there should be zero Elm runtime errors.
- Toggle polling and confirm the network tab shows a GET request every 60 seconds while polling is active.
If a request fails (e.g., network offline), the GotUsers (Err _) branch runs and the UI stays unchanged – a safe, predictable fallback.
Actionable Closing
Adopt the pattern: declare every side effect as a Cmd or Sub, keep batches tiny, and tie subscription lifecycles to a clear model flag. This gives you compile‑time guarantees that no hidden I/O slips through, while the runtime stays simple enough to debug with ordinary browser tools.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.