https://popcorn.swmansion.com/
Getting started API Limitations Under the hood
[logo-full] 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
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:
+ onStdout ((text: string) => void) - a function that receives
any text from standard output. Defaults to no-op function.
+ onStderr ((text: string) => void) - a function that receives
any text from standard error. Defaults to no-op function.
+ container (DOMElement) - a DOM element that iframe should be
mounted at. Read more in "Under the hood" section. Defaults
to document.body.
+ bundlePath (string) - a path to the compiled Elixir code
bundle. Defaults to static/wasm/app.avm.
+ heartbeatTimeoutMs (number) - a time limit set for iframe to
send heartbeat message. Read more in "Under the hood"
section. Defaults to 15s.
+ debug (boolean) - an option to enable internal logs used to
debug the library. Defaults to false.
Methods used to interact with Elixir from JS:
* async call(args, options) - takes a serializable value in JS,
sends a message to registered Elixir process, and waits for
Elixir code to settle the promise. Options:
+ process (string) - name of the process that will receive the
message. Defaults to the process name set in
Wasm.send_elixir_ready/1 call.
+ timeoutMs (number) - a time limit set for Elixir to settle
the promise. After that time promise is automatically
rejected. Defaults to 5s.
* cast(args, options) - takes a serializable value in JS and sends
a message to registered Elixir process. Options:
+ process (string) - name of the process that will receive the
message. Defaults to the process name set in
Wasm.send_elixir_ready/1 call.
To destroy an instance, use popcorn.deinit() method.
Elixir
Main component is the Popcorn.Wasm module that handles communication
with JS.
* send_elixir_ready(opts) - a function that notifies JS that Elixir
finished initialization. Opts:
+ default_receiver (string or atom) - sets the default receiver
for JS calls and casts. Optional.
* is_wasm_message(raw_message) - a guard that returns true if
argument is a raw message received from JS.
* handle_message!(raw_message, handler) - parses raw message
received from JS and dispatches it to handler.
For :wasm_call, handler should return {promise_status,
promise_value, result} tuple, where:
+ promise_status is either :resolve or :reject,
+ promise_value is any serializable value that JS should
receive in response,
+ result is any value passed back to the caller.
Popcorn resolves the JS promise with it, finishing the call.
For :wasm_cast message, it should return only result.
* run_js(js_function, opts) - Executes JS function in the iframe
context and returns a map containing reference to JS object
(RemoteObject struct).
The JS function takes an object and returns any value. The object
contains:
+ bindings - an object with serializable values passed from
Elixir in bindings option.
+ window - a JS window bound to main browser context. Used for
DOM manipulation.
Value returned from JS function will be returned to Elixir in
form of RemoteObject. If returned value is serializable, it can
be retrieved in Elixir by using return option described below.
Opts:
+ bindings - a map of serializable Elixir values that will be
passed to JS function. Defaults to %{}.
+ return (list) - if :value is included in the list, run_js/2
will additionally include serializable JS value in returned
map. Defaults to [:ref].
* register_event_listener(event_name, opts) - registers event
listener for event_name events (e.g. "click"). Opts:
+ selector (string) - a selector for DOM element that listener
will attach to.
+ target (atom or string) - a name of the process that will
receive the events.
+ event_keys (list) - a list containing atom names of event
object. The specified keys will be included in the message.
* unregister_event_listener(ref) - unregisters event listener
referenced by ref.
* parse_message!(raw_message) - a low level function that parses JS
message.
* resolve(term, promise) - a low level function that resolves JS
promise with serializable term.
* resolve(term, promise) - a low level function that rejects JS
promise with serializable term.
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
[arch]
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
[swm-logo]
Licensed under the Apache License, Version 2.0.