Run Elixir in WASM

Popcorn is a library that enables execution of Elixir code within web browsers.

Compiled Elixir code is executed in the client-side AtomVM runtime. Popcorn offers APIs for interactions between Elixir and JavaScript, handling serialization and communication, as well as ensuring browser responsiveness.

We prepared three live examples using Popcorn, check them out!
You will find Popcorn API in "API" section and read how it all works in "Under the hood" section.

Popcorn in action

REPL example
A simple Elixir REPL, compiling code dynamically in WASM.
Hexdocs
Elixir docs "Getting started" guide with interactive snippets.
Game of life
Game of life, representing every cell as a process.

Getting started

Note

This library is work in progress. API is unstable and some things don't work. You can read more in "Limitations" section.

Popcorn connects your JS and Elixir code by sending messages and directly executing JS from Elixir. To do that, you need to setup both JS and Elixir.

Add Popcorn as a dependency in your mix.exs{:popcorn, "~> 0.1"} and run mix deps.get. After that, setup JS and Elixir WASM entrypoint.

JS

First, generate a directory that will host Popcorn JS library, WASM, and generated app bundle. To do that, run:

$ mix popcorn.build_runtime --target wasm --out-dir static/wasm

Next, in your main html you need to include the library and code that sets up communication channels with Elixir. Add those scripts at the end of the body element in HTML.

HTML snippet
# static/index.html
<script type="module" src="wasm/popcorn.js" defer></script>
<script type="module" defer>
    import { Popcorn } from "./wasm/popcorn.js";
    const popcorn = await Popcorn.init({
        onStdout: console.log,
        onStderr: console.error,
    });
</script>

WASM Entrypoint

A WASM entrypoint is any Elixir module with start/0 function that never exits. If you are using supervision tree, you can write it as follows:

Entrypoint snippet
# lib/app/application.ex
defmodule App.Application do
    use Application
    alias Popcorn.Wasm

    @receiver_name :main

    # entrypoint
    def start do
        {:ok, _pid} = start(:normal, [])
        Wasm.send_elixir_ready(default_receiver: @receiver_name)
        Process.sleep(:infinity)
    end

    @impl true
    def start(_type, _args) do
        # Create default receiver process and register it under `@receiver_name`
        # ...
    end
end

After we finish initializing Elixir (setting up supervision trees, etc), we notify JS side by calling Wasm.send_elixir_ready/1. For convenience, we also pass name of the default receiver process. JS will send messages to it if no other process name is specified.

We need to set entrypoint name in the config:

Config snippet
# config/config.ex
config :popcorn, start_module: App.Application

At this point, your application is ready to exchange messages between JS and Elixir. Next, we will implement Elixir GenServer that will process JS messages and interact with DOM.

Elixir receiver process

This is a process that will receive messages originating from JS. See the "API" section for details on how to receive messages to JS and how to call JS code.

API

JS

Main component is the Popcorn class that manages the WASM module and sends messages to it.

To create an instance, use Popcorn.init(options) static method. Options:

Methods used to interact with Elixir from JS:

To destroy an instance, use popcorn.deinit() method.

Elixir

Main component is the Popcorn.Wasm module that handles communication with JS.

Limitations

We rely on AtomVM for running the compiled beams. It's a runtime designed for microcontrollers and it doesn't entire OTP. Most notably, some natively implemented functions (NIFs) from OTP standard library are missing. We provide patches, reimplementing some in Erlang and work on adding important NIFs directly to AtomVM. Nevertheless, some modules (e.g. :timer, full :ets selects – core Elixir code depend on them) won't work just yet.

Aside of parts of standard library, AtomVM doesn't support big integers and bitstring well. There's ongoing work to support both of those.

Popcorn provides set of functions that work with JS. Not all values can be sent to either JS or Elixir. Working with those values is based on passing opaque references to them.

API is not stabilized yet but we mostly want to keep the current form for JS and slightly improve developer experience for Elixir parts.

Under the hood

Overall architecture

To run Elixir on the web, you need to compile Erlang/Elixir runtime to WASM and load the compiled Elixir bytecode. We use AtomVM runtime. It is compiled via Emscripten and loaded in iframe to isolate main window context from crashes and freezes. The runtime then loads user's code bundle with .avm extension. The bundle is a file consisting of concatenated .beam files.

This flow guides the architecture – main window creates an iframe and communicates with it via postMessage(). Script in the iframe loads WASM module and code bundle. The WASM module initializes the runtime on multiple webworkers. Main window sets up the timeouts which trigger if call() takes too long or if iframe doesn't respond in time (most likely crashed or got stuck on long computation).

When initializing WASM module, the script in iframe also waits for a message from Elixir. This ensures we can't send messages to Elixir before we can process them.

Patching

In order to use Elixir and Erlang standard library, we use custom patching mechanism. It takes .beams from known version of Erlang and Elixir, optionally patching them with our changes. This allows for overriding behavior (working around missing functionality in AtomVM) and adding modules such as :emscripten to standard library. This mechanism is currently not exposed to end users.

Elixir and JS communication

JS calls and casts are extensions for WASM platform in AtomVM. Both allows sending messages with string or number data to named processes. call() additionally creates a promise that Elixir code needs to resolve to complete the request.

Popcorn builds on this mechanism to allow sending any structured data. We use JSON as serialization strategy.

For Elixir communication with JS, we use Emscripten API to make a JS call in the iframe JS context. Any scheduler on worker thread can queue a JS call to be executed on main browser thread. We expose a function that takes JS function as a string and return any value. This value is persisted in global map in JS under unique key and function returns a reference to the key. If Elixir loses this reference, the value is removed from the JS map.

If value returned from JS function is serializable, you can use return: :value option to send the value back to the Elixir.

About

Popcorn is created by Software Mansion.

Since 2012 Software Mansion is a software agency with experience in building web and mobile apps as well as complex multimedia solutions. We are Core React Native Contributors and experts in live streaming and broadcasting technologies. We can help you build your next dream product – Hire us.

Copyright 2025, Software Mansion

Licensed under the Apache License, Version 2.0.