Elixir
Popcorn 1.0: Moving from AtomVM to OTP/BEAM
Kuba Gonet profile pictureKuba Gonet•Oct 27, 2026•9 min read

Popcorn is a library that lets you run Elixir in the browser. We recently released Popcorn 1.0, which replaces AtomVM with OTP/BEAM as the underlying VM and marks it as a first stable release.

For browser applications built with Popcorn, this gives much broader OTP compatibility, makes it much harder to crash or hang Popcorn's VM, and creates a stable foundation for future releases.

I want to take a moment to talk about the whys and hows.

Why we moved away from AtomVM

Popcorn started as a research project. We wanted to see how far we could take the "Elixir on the web" idea, and where it would break down. We took it surprisingly far, considering that the Wasm ecosystem is still developing.

We decided to run a real VM in the browser. Compared to other approaches (compiling .beam files to Wasm or Elixir AST to JS), this has the upside of supporting Elixir semantics closely.

We used AtomVM as the underlying runtime. It's a small VM aimed at embedded uses, implementing a subset of OTP/BEAM. At the time, it seemed like a perfect fit for browsers, which in some ways resemble embedded targets; the VM itself is small, hackable, and frankly, it was enjoyable to work on its code.
We also didn't think the BEAM could be compiled to Wasm. It's quite a complex project with a long history. It isn't optimized for binary size and browser speed. Additionally, we saw some previous attempts to compile it for browser use, and they didn't look promising.

Over time, we started to feel that fixing compatibility problems with AtomVM consumed a lot of our time.
Our main goal was to use unmodified Elixir code. Unfortunately, it wasn't possible on AtomVM, so we maintained a lot of patches to the standard library. We did our best to fix bugs, implement features, and send the patches upstream. That lasted about a year. Maintaining our fork while shipping Popcorn features became more and more difficult.
Making it 100% compatible with BEAM isn't feasible — BEAM itself is a moving target, and the language, bytecode format, and VM semantics aren't specified at all. AtomVM folks have to track it closely while maintaining the trade-offs needed for embedded use (e.g., some standard library modules in AtomVM have an additional error path for out-of-memory errors).
If only we could use OTP/BEAM directly...

At the end of last year (2025), Mateusz Furga joined the team and experimented with compiling OTP/BEAM to Wasm. At the time, it didn't work, but I felt it was very close.
In January, I asked an LLM to try building on that work, with absolutely no expectations about the result. Over the course of three days, during which I mostly nudged it in the right direction and shot down bad ideas, we got something working.

The prototype took about ten seconds to start and its assets were large. Still, the VM was stable, and the port didn't require extensive patching of OTP’s C code.

After confirming that it could be done, we focused on moving over bits and pieces from the proof of concept. We took it as an opportunity to revisit decisions we had made for the AtomVM implementation —[1] the main ones concerned browser isolation and communication with JS (which is an important part of Popcorn).

A month ago, I felt it was close to the point where I'd be happy with it, and I moved the examples and Elixir Language Tour to the new runtime.

How did it turn out?

Initially, I was skeptical about the size and the speed of the OTP/BEAM port. It turned out that the ten-second boot was an easily resolved issue[2], and we could additionally shave off a lot of unnecessary bytes.

As it stands today, we ship a 730 KB Wasm binary. All assets are Brotli-compressed, and Brotli happens to be a really good compression scheme. Booting the VM takes around 200 ms.

We also include a build with a cryptographic library and one without. Most applications can work without the library, but if you're using SSL, checking private/public keys, or generating secure hashes, you need crypto support.

For comparison, AtomVM is around 250 KB compressed, booting in 80 ms.
Along with the VM binary, we need to add around 2.5 MB for standard library files (although they can be tree-shaken, reducing the size by removing unneeded modules).

popcorn-sizes.png

The difference between BEAM (no crypto) and AtomVM is around 720 KB.

 

popcorn-timings-v2.png

AtomVM starts 3.2× faster, but OTP executes the measured workloads 3.4×–1,800× faster.

A few things from the graph: BEAM is comparable when running compiled code, although booting is slower (~240 ms vs. 80 ms). For interpreted code, BEAM is much faster—it takes 70 ms to evaluate a module and run its code. AtomVM takes 240 ms.

Since writing this blog post, I managed to bring BEAM boot time down to ~200 ms, with 140 ms on a warm boot.

I wrote more about OTP/BEAM optimizations in this blog post. I found one OTP/BEAM bug and one undocumented feature working against us there.

Overall, things just work. BEAM gave us features that were missing from the version of AtomVM we were using (some have since been implemented): bitstrings, regex, full ETS, protocols, things around system/process_info, missing NIFs, and much more[3].
We've been running Elixir Language Tour and our landing page with it for a while. Recently, we also switched Local LiveView to OTP/BEAM.

The changes

As mentioned, we changed how Popcorn works quite a bit. Let me show you some highlights.

Embracing "let it crash"

Previously, we used iframes to isolate VM crashes from the main browser window. They have some quirks you need to be mindful of (such as not being isolated at all when they share an origin with the main window, as we learned).

We decided to move fully to Web Workers, which are a better fit for this kind of usage. This let us run multiple VM instances, simplified the code for actually running the thing, and isolated the main window from misbehaving VMs.
The BEAM is much more stable than the previous runtime, and the few crashes we observed were connected to our own patches (which we try to keep to a minimum, their implementation has been fixed since).

We refined our philosophy on handling errors in JS. We fail fast when you misuse the API, throwing exceptions. For expected errors that should be handled, we use Result-typed return values. This improves both your experience as a user and our experience when developing Popcorn.

Packaging

People ask me in the letters: "What about the standard library?"[4] I've got you covered.

For shipping the standard library to the web, we previously used heavily patched stdlib beams bundled into an .avm file. With OTP/BEAM, we took a simpler route: take all apps' .beam files, throw 'em into a .tar, and compress 'em into a .tar.br archive. It works surprisingly well, especially on the compression front.

We don't have many things to work around, either! Just the usual browser limitations (sockets, DNS, filesystem) and some modules that depend on now-removed NIFs.

One pretty heavy part of OTP/BEAM is the crypto app. To provide it, we need to compile OpenSSL (which BEAM uses for crypto) into the Wasm binary. Unless you need cryptographically random bytes or algorithms (hello, mix install), you can use a version without it, which is a tad lighter.

Making requests

One problem with running OTP in a browser is that the usual networking stack isn't there. Req, a popular library for making requests, normally uses Finch, which expects TCP sockets, which aren't available in browsers.

We created the Popcorn.Fetch module, which implements a Req adapter backed by the browser's fetch() API. We use it as the default adapter in Popcorn, but you still have the option to use your own adapter if you want to.

While fetch() has some limitations related to CORS, redirects, and which headers can be set, you can now use Req for your requests without reaching for JS APIs.

New API

For a long time, Popcorn had call() and cast() which pretended to do the thing they were named after but really just sent a plain message to the process.
This was awkward — you needed to use handle_info/2 and transform the message before you could handle it.
We now use a version of it that actually forwards them to handle_call and handle_cast and things work just as you might expect.

The plain send() function was finally added as well. In JS, its signature is:

async send(rawTarget: string | Pid, payload: AnyValue): Promise<void>

This means that you can pass a named process or "PID handle" obtained from Elixir (via JS.run() return value) and send an arbitrary payload. The function returns a Promise that resolves after a message is put in the target process's mailbox.
It has slightly different behavior from OTP/BEAM send() (which ignores invalid PIDs) but it's a better fit for JS.

When redesigning the API, we focused on creating a fairly simple API surface that guides you toward success[5] and requires minimal configuration. I encourage you to read the docs to learn more.

Upgrading

The entire process for migrating from older versions of Popcorn is described in the documentation and release notes. Things work pretty much the same, and all breaking changes are pretty mechanical (for example, this PR migrates Elixir Language Tour to OTP/BEAM).

I also put some work to write guides for newcomers, both ones that are more familiar with Elixir and ones that prefer JavaScript.

What's left?

One project we have in the works is PopDoc (an ExDoc plugin for interactive docs), and we plan to improve Local LiveView and treeshaking a little bit.

For Popcorn itself, we just need some finishing touches — examples, and optimizations for size and speed. The API is quite stable. I'd personally like to see some APIs built on top of Popcorn.JS.run/3, making it easier to interact with JS code (e.g. libraries) but keeping it in Elixir code.

I know few people waited for Popcorn that's compatible with newer versions of the BEAM. For previous version this was very hard. Now, updates for us should be simpler and you can expect support for new BEAM versions coming much faster.

Closing thoughts

So far, this has been a really cool project to work on, and I'm grateful to Software Mansion for sponsoring the work and to everyone on the team, past and present.

Please give it a try. If you have any ideas, feedback, or cool demos, share them with me — I'm always happy to see Popcorn in use.

Until next time!


  1. My em-dashes are generated in a controlled, organic environment. They live a happy life there, actually.↩︎
  2. After benchmarking, we discovered that we spent a lot of time during boot waiting for stdin input. stdin was implemented using Atomics in Popcorn and got a good night's sleep for 10 ms with every read. Fine for a few stdin reads, not so much for 1 300 reads.↩︎
  3. Here's the full list of patches.↩︎
  4. Both "People" and "asking in letters" may or may not be pure fiction. I'd love to receive a handwritten letter one of these days!↩︎
  5. https://blog.codinghorror.com/falling-into-the-pit-of-success/↩︎
Share this article