Using Elm Ports for Safe JavaScript Interop
Learn how Elm ports let you safely exchange JSON data with JavaScript, see a minimal Elm‑JS example, and understand the limits and pitfalls.
02 Sept 2026, 18:18 UTC

Quick answer
Elm ports let you send and receive JSON‑serializable data between Elm and JavaScript while preserving Elm’s guarantee of no runtime exceptions. Define an outgoing port as a Cmd and an incoming port as a Sub, then handle the messages in the update function.
Worked example
Elm side (src/Main.elm)
module Main exposing (main)
import Browser
import Html exposing (Html, button, text)
import Html.Events exposing (onClick)
-- OUTGOING port: Elm → JS
port logMessage : String -> Cmd msg
-- INCOMING port: JS → Elm
port incomingMessage : (String -> msg) -> Sub msg
-- Model and Msg
type alias Model = {
lastReceived : String
}
type Msg =
Receive String
SendClick
init : () -> (Model, Cmd Msg)
init _ = ({
lastReceived = ""
}, Cmd.none)
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Receive txt ->
({ model | lastReceived = txt }, Cmd.none)
SendClick ->
(model, logMessage "Button clicked from Elm")
view : Model -> Html Msg
view model =
div []
[ button [ onClick SendClick ] [ text "Send to JS" ]
, div [] [ text ("Last from JS: " ++ model.lastReceived) ]
]
main : Program () Model Msg
main =
Browser.sandbox { init = init, update = update, view = view }
JavaScript side (index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Elm Ports Demo</title>
</head>
<body>
<div id="elm-root"></div>
<script src="main.js"></script>
<script>
// Assume the Elm app was embedded with Elm.Main.init
const app = Elm.Main.init({ node: document.getElementById('elm-root') });
// Outgoing: listen to messages from Elm
app.ports.logMessage.subscribe(function (value) {
console.log('From Elm:', value);
// Echo back a response
app.ports.incomingMessage.send('Echo: ' + value);
});
// Incoming: send a message to Elm when the page loads
app.ports.incomingMessage.send('Hello from JS on load');
</script>
</body>
</html>
Build and run
- Compile Elm: Run
elm make src/Main.elm --output=main.jsin the project root. No special permissions are needed; the command writesmain.jsto the current directory. - Open the page: Serve
index.htmlwith any static file server (e.g.,python -m http.server 8000) and navigate tohttp://localhost:8000. - Check the console: You should see logs like "From Elm: Button clicked from Elm" and "Echo: Button clicked from Elm". The UI will update the "Last from JS" text when a message arrives.
How it works
The Elm runtime treats each port as a boundary where values are automatically serialized to JSON when leaving Elm and deserialized when entering Elm. Because the port types are String -> Cmd msg and (String -> msg) -> Sub msg, the compiler guarantees that only JSON‑representable values (here, strings) can cross. On the JavaScript side you interact with the generated app.ports object using send for outgoing ports and subscribe for incoming ports.
Limits and common mistakes
- Only JSON‑serializable data: Trying to pass an Elm
Dict, a function, or a cyclic structure results in a compile‑time error because the type cannot be expressed asJson.Encode.Value. You must manually encode/decode withJson.EncodeandJson.Decodeif you need richer structures. - Asynchronous nature: Port messages are delivered as macrotasks; there is no guarantee that a message sent from Elm will be processed by JavaScript before the next Elm update. Assuming immediate synchronous response can cause race conditions, especially during initialization.
- Missing subscriptions: If you forget to call
app.ports.incomingMessage.subscribeon the JS side, incoming messages are silently dropped. Likewise, neglecting tosubscribeto an outgoing port means you never see the data Elm sends. - Port name clashes: Each port must have a unique name in the Elm module; duplicate names cause a compile error.
Practical verification
After building, open the browser console and:
- Click the "Send to JS" button and verify that a log appears from Elm and that the echoed response updates the UI.
- Change the Elm port definition to
port badPort : (Int -> Int) -> Cmd msg(a function) and runelm makeagain; the compiler will reject the code with a type‑mismatch error, confirming the safety guarantee. - Remove the
subscribeline in JavaScript, reload, and notice that messages from Elm no longer appear in the console—demonstrating the need for an active subscription.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.