Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Language Atlas (mini-courses for more languages)  Module 10 – Gleam on the BEAM and in JavaScript

Gleam processes, actors and FFI

Spawn BEAM processes that talk through typed subjects, build a gleam_otp actor, restart it under a supervisor, and call Erlang and JavaScript code safely.

  • Intermediate
  • 15 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Gleam 1.18.1, Erlang/OTP 29.1.1, Node.js 24.21.0, gleam_erlang 1.3.0 and gleam_otp 1.3.0
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Start processes that send messages through a subject, and give every receive a timeout
  • Build an actor with gleam_otp that keeps state, answers calls and never sells a unit twice
  • Run an actor under a supervisor, and predict which state survives its restart
  • Call Erlang and JavaScript code through @external functions, and explain what the compiler cannot check

Before you start

What you’ll learn · Before you start

What you will learn

  • Start processes that send messages through a subject, and give every receive a timeout
  • Build an actor with gleam_otp that keeps state, answers calls and never sells a unit twice
  • Run an actor under a supervisor, and predict which state survives its restart
  • Call Erlang and JavaScript code through @external functions, and explain what the compiler cannot check

Before you start

On this page

Ten processes asked one Gleam actor for a unit of stock at the same moment, with four units left, and exactly four got one: there was no lock and no transaction, only a process that handles one message at a time. On the BEAM, Gleam programs are built from such processes, and when one crashes, a supervisor starts it again, with its first state rather than its last.

Here you start processes and pass them typed messages, turn one into an actor that keeps stock, crash it under a supervisor and watch what comes back, and then call Erlang and JavaScript code from Gleam, including a binding whose type is wrong. Every program ran on Erlang/OTP 29.1.1 with gleam_erlang 1.3.0 and gleam_otp 1.3.0, which work on the Erlang target only; the last section runs on Node.js 24.21.0 too.

A process runs one function, and a subject carries its messages

Erlang’s reference manual calls its process (BEAM): A lightweight unit of execution on the BEAM, with its own memory and its own mailbox. Processes share nothing and work together by sending each other messages; in Gleam, process.spawn from gleam_erlang starts one. lightweight: small in memory, fast to create and end, and cheap to schedule. Its guide to concurrent programming adds that they share no data, which is the reason for the name: each has a message queue, its mailbox, and they work together by sending messages. In Gleam, process.spawn from gleam_erlang starts one, running the function you give it beside the process that started it. Messages travel through a subject (Gleam): A value through which processes send messages of one type, such as Subject(Int). It is owned by the process that created it: any process may send to it, and only the owner may receive from it.: the documentation describes it as a value for sending and receiving messages in a well-typed way. A subject belongs to the process that made it, so any process may send to it but only that owner may receive. counter.gleam starts five processes, and each sends the square of its number back to main:

counter.gleam: five processes, one subject GLEAM · processes/src/counter.gleam
import gleam/erlang/process
import gleam/int
import gleam/io
import gleam/list
import gleam/string

pub fn main() -> Nil {
  // A subject belongs to the process that made it: here, main.
  let results = process.new_subject()

  // Five processes, each sending one message back to main.
  list.each([1, 2, 3, 4, 5], fn(n) {
    process.spawn(fn() { process.send(results, n * n) })
  })

  // Five receives, each waiting at most 1000 ms for the next message.
  let squares =
    list.map([1, 2, 3, 4, 5], fn(_) {
      let assert Ok(square) = process.receive(results, within: 1000)
      square
    })
  io.println("sum of the squares: " <> int.to_string(int.sum(squares)))
  io.println(
    "sorted: "
    <> list.sort(squares, int.compare)
    |> list.map(int.to_string)
    |> string.join(", "),
  )

  // A sixth receive: nobody sends again, so it gives up after 100 ms.
  case process.receive(results, within: 100) {
    Ok(_) -> io.println("an unexpected sixth message")
    Error(Nil) -> io.println("no sixth message within 100 ms: Error(Nil)")
  }
}

Step through the run, from the subject to the receive that finds nothing:

Interactive · message by message Five processes send their squares to main Step through counter.gleam, from the subject main creates to the receive that finds nothing.
Five processes send their squares to main, step 6 of 6. Messages so far: main to Five processes: spawn × 5; Five processes to main's mailbox: 1, 4, 9, 16, 25; main's mailbox to main: five messages.mainowns the subjectFive processesone for each nmain's mailboxmessages queue herespawn × 51, 4, 9, 16, 25five messagesempty: Error(Nil)

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A subject. process.new_subject() makes a subject owned by main. Any process may send to it; only main may receive from it.
  2. Spawn five. list.each starts one process for each n from 1 to 5 with process.spawn. Each runs its own function, beside main, and each closes over results.
  3. Send the squares. Each process sends n * n to results and ends. The messages land in main's mailbox in whatever order the processes ran, which is why the program sorts before it prints.
  4. Five receives. main calls process.receive(results, within: 1000) five times. Each call takes one message for this subject, or gives up after 1000 ms with Error(Nil).
  5. The sum. All five arrived, so main printed sum of the squares: 55 and the sorted list 1, 4, 9, 16, 25.
  6. A sixth receive. Nobody sends a sixth message, so receive(results, within: 100) returned Error(Nil) after 100 ms: a timeout is a value, not a hang.

The five squares, and a receive that times out Python · processes_session.py
from session import run

# Five processes each send one square back to main through main's subject; then a receive that times out.
run(
    """
cd processes
gleam run -m counter
"""
)

Output

$ cd processes
$ gleam run -m counter
Downloading packages
 Downloaded 4 packages in …s
  Compiling gleam_stdlib
  Compiling gleam_erlang
  Compiling gleam_otp
  Compiling gleeunit
  Compiling processes
   Compiled in …s
    Running counter.main
sum of the squares: 55
sorted: 1, 4, 9, 16, 25
no sixth message within 100 ms: Error(Nil)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 processes_session.py

All five messages arrived and main printed 55. The messages land in main’s mailbox: The queue of messages that a BEAM process has received and not yet handled. Messages from one sender arrive in the order they were sent; messages from several senders have no fixed order. in whatever order the five processes happened to run, so the program sorts them before it prints them; a program whose output depends on that order is a program that prints differently from run to run. The sixth process.receive(results, within: 100) found no message and returned Error(Nil) after 100 ms: a receive with a timeout is a Result, so waiting forever is never an accident.

Receiving on a subject another process made

Only the owner of a subject may receive from it; the documentation says receive panics when another process tries. Make the subject in the process that will wait for the answers, as main does here, and pass it to the processes that send.

Checkpoint

Check: Processes and subjects

  1. Question 1 of 2 In counter.gleam, main creates the subject results and five processes use it. Who may receive from it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Only main, the process that created it; any process may send to it

    Create the subject in the process that will wait for the answers and hand it to the senders. That is the shape of every request and reply in Gleam, process.call included.

  2. Question 2 of 2 What did the sixth process.receive(results, within: 100) return, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Error(Nil) after 100 ms, because no process sent a sixth message

    A receive with a timeout is a Result, so a program decides what a missing answer means instead of hanging.

An actor is a process that keeps state and handles one message at a time

A process that loops over its mailbox and keeps some state is the commonest thing on the BEAM, and gleam_otp’s actor (gleam_otp): A process that keeps state and handles the messages of one type one at a time: a handler takes the current state and a message and returns the next state. gleam_otp's actor module provides it, with a typed interface. is that loop, typed. Its documentation compares it with Erlang’s gen_server and Elixir’s GenServer, with a fully typed interface, and says an actor handles messages sequentially, one after the other. You write two things: the type of the messages it accepts, and a handler that takes the current state and one message and returns the next state with actor.continue. A message that wants an answer carries a subject for it, and process.call creates that subject, sends the message and waits for the reply:

stock.gleam: an actor that keeps units of stock GLEAM · processes/src/stock.gleam
import gleam/erlang/process.{type Subject}
import gleam/int
import gleam/io
import gleam/list
import gleam/otp/actor
import gleam/result

/// The messages the stock actor understands. A message that wants an answer
/// carries the subject to send the answer to.
pub type Message {
  Reserve(units: Int, reply: Subject(Result(Int, String)))
  Release(units: Int)
  Left(reply: Subject(Int))
}

/// The current stock and one message in, the next stock out.
pub fn handle(left: Int, message: Message) -> actor.Next(Int, Message) {
  case message {
    Reserve(units:, reply:) if units <= left -> {
      process.send(reply, Ok(left - units))
      actor.continue(left - units)
    }
    Reserve(reply:, ..) -> {
      process.send(reply, Error("only " <> int.to_string(left) <> " left"))
      actor.continue(left)
    }
    Release(units:) -> actor.continue(left + units)
    Left(reply:) -> {
      process.send(reply, left)
      actor.continue(left)
    }
  }
}

pub fn start(units: Int) -> Subject(Message) {
  let assert Ok(started) =
    actor.new(units) |> actor.on_message(handle) |> actor.start
  started.data
}

pub fn main() -> Nil {
  let stock = start(5)
  let _ = echo process.call(stock, 100, Reserve(2, _))
  let _ = echo process.call(stock, 100, Reserve(4, _))
  process.send(stock, Release(1))
  echo process.call(stock, 100, Left)

  // Ten buyers at once, each a process asking for one unit.
  let answers = process.new_subject()
  list.each(list.repeat(Nil, 10), fn(_) {
    process.spawn(fn() {
      process.send(answers, process.call(stock, 1000, Reserve(1, _)))
    })
  })
  let results =
    list.map(list.repeat(Nil, 10), fn(_) {
      let assert Ok(answer) = process.receive(answers, within: 1000)
      answer
    })
  let sold = list.count(results, result.is_ok)
  io.println(int.to_string(sold) <> " of 10 buyers got a unit")
  echo list.unique(list.filter(results, result.is_error))
  echo process.call(stock, 100, Left)
  Nil
}

main starts the actor with 5 units, makes three calls and one send, then starts ten processes that each ask for one unit at the same time. The session runs it, then the module’s three tests:

Calls to the stock actor, ten buyers at once, then its tests Python · actor_session.py
from session import run

# The stock actor: three calls and a send, then ten buyers at once against the four units left, then its tests.
run(
    """
cd processes
gleam run -m stock
gleam test
"""
)

Output

$ cd processes
$ gleam run -m stock
Downloading packages
 Downloaded 4 packages in …s
  Compiling gleam_stdlib
  Compiling gleam_erlang
  Compiling gleam_otp
  Compiling gleeunit
  Compiling processes
   Compiled in …s
    Running stock.main
src/stock.gleam:43
Ok(3)
src/stock.gleam:44
Error("only 3 left")
src/stock.gleam:46
4
4 of 10 buyers got a unit
src/stock.gleam:62
[Error("only 0 left")]
src/stock.gleam:63
0
$ gleam test
   Compiled in …s
    Running processes_test.main
...
3 passed, no failures

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 actor_session.py

The first calls ran in order: Reserve(2) replied Ok(3), Reserve(4) was refused with Error("only 3 left"), and after Release(1) there were 4. Then exactly 4 of the 10 buyers got a unit, all six others got Error("only 0 left"), and 0 units were left. Step through what happened when two of the buyers reached the last unit:

Interactive · message by message Ten buyers, four units, one actor Step through the stock actor's mailbox as two of the ten buyers ask for the last unit at the same moment.
Ten buyers, four units, one actor, step 6 of 6. Messages so far: Buyer A to The stock actor: Reserve(1); Buyer B to The stock actor: Reserve(1); The stock actor to Buyer A: Ok(0); The stock actor to Buyer B: Error("only 0 left").Buyer Aa processThe stock actorstate: units leftBuyer Banother processReserve(1)Reserve(1)Ok(0)Error("only 0 left")

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Three units sold. Three buyers have already been served, one unit each, so the actor's state is 1: the last of the 4 units left after Reserve(2), the refused Reserve(4) and Release(1).
  2. Two calls at once. Buyers A and B both call process.call(stock, 1000, Reserve(1, _)). Each call sends a message that carries a new subject for the reply, then waits.
  3. A queue, not a race. Both messages wait in the actor's mailbox. The actor runs handle for one message at a time, so the second waits until the first is done.
  4. A gets the unit. handle(1, Reserve(1, …)): 1 <= 1, so it replies Ok(0) and continues with state 0. Nothing else ran while it did.
  5. B is told no. handle(0, Reserve(1, …)): the guard fails, so it replies Error("only 0 left") and keeps state 0. No unit is sold twice.
  6. The whole run. Across all ten buyers this is the session's result: 4 of 10 buyers got a unit, the other six got the same Error, and 0 units are left.

No code took a lock: the ten messages waited in one mailbox, and handle ran for one at a time, each time against the state the previous message left. The tests make it a rule, with twenty buyers against five units.

Key idea

An actor owns its state and handles one message at a time, so a check and an update in the same handle call can never be split by another request: the actor serialises them.

Checkpoint

Check: Actors that keep state

  1. Question 1 of 2 What does the stock actor's handle function return after it has replied to a Reserve message?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: actor.continue with the next state, the units left after the reservation

    An actor is a loop: state and message in, next state out. The reply is a message of its own, sent to the subject the caller put in the request.

  2. Question 2 of 2 Ten buyers each asked for one unit at the same moment, with 4 units left. Why did exactly 4 get one?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The actor handled the ten messages one at a time, each against the state the previous one left

    Serialising every request through one process is the BEAM's way to keep a shared resource consistent, with no locks to forget.

A supervisor restarts a crashed actor, but not its state

The BEAM’s answer to a crash is to let the process die and start a fresh one. A supervisor: A process that starts other processes, its children, and starts a child again when it ends with a crash, by calling its start function, so the new child begins from its starting state. does that: gleam_otp’s static supervisor starts its children and, under the OneForOne strategy, restarts only the child that ended. The child here is the stock actor, registered under a name made with process.new_name, so that callers keep a named subject that reaches whichever process holds the name. The handler has one extra message, Crash, which panics:

supervised.gleam: a named actor under a supervisor GLEAM · processes/src/supervised.gleam
import gleam/erlang/process.{type Subject}
import gleam/int
import gleam/io
import gleam/otp/actor
import gleam/otp/static_supervisor as supervisor
import gleam/otp/supervision

pub type Message {
  Reserve(units: Int, reply: Subject(Result(Int, String)))
  Left(reply: Subject(Int))
  Crash
}

fn handle(left: Int, message: Message) -> actor.Next(Int, Message) {
  case message {
    Reserve(units:, reply:) if units <= left -> {
      process.send(reply, Ok(left - units))
      actor.continue(left - units)
    }
    Reserve(reply:, ..) -> {
      process.send(reply, Error("only " <> int.to_string(left) <> " left"))
      actor.continue(left)
    }
    Left(reply:) -> {
      process.send(reply, left)
      actor.continue(left)
    }
    Crash -> panic as "the stock actor was told to crash"
  }
}

/// The child the supervisor starts, and starts again after a crash: an actor
/// with 5 units, registered under `name` so that callers never hold its pid.
fn start(name: process.Name(Message)) {
  actor.new(5)
  |> actor.on_message(handle)
  |> actor.named(name)
  |> actor.start
}

pub fn main() -> Nil {
  let name = process.new_name("stock")
  let assert Ok(_) =
    supervisor.new(supervisor.OneForOne)
    |> supervisor.add(supervision.worker(fn() { start(name) }))
    |> supervisor.start
  let stock = process.named_subject(name)

  let _ = echo process.call(stock, 100, Reserve(2, _))
  process.send(stock, Crash)
  process.sleep(100)
  io.println("after the restart:")
  let _ = echo process.call(stock, 100, Left)
  Nil
}

give_up.gleam is the same program with a different main: it sends Crash every 100 ms, forever. Step through both runs, then read what the BEAM printed:

Interactive · message by message A crash, a restart, and the state that comes back Step through supervised.gleam, then the run of give_up.gleam in which the supervisor stops trying.
A crash, a restart, and the state that comes back, step 8 of 8. Messages so far: The supervisor to The stock actor: start(name), 5 units; main to The stock actor: Reserve(2); main to The stock actor: Crash; The stock actor to The supervisor: exit signal; The supervisor to The stock actor: start(name) again; The stock actor to main: 5; The supervisor to main: exit signal.maincalls by nameThe supervisorone for oneThe stock actornamed stockstart(name), 5 unitsReserve(2)Crashexit signalstart(name) again5exit signal

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Start under a supervisor. main starts a supervisor with one worker: an actor with 5 units, registered under a name. main keeps a named subject, not a pid.
  2. Reserve 2. A call through the named subject reaches the actor, which replies Ok(3) and continues with 3 units.
  3. The crash. main sends Crash, and handle panics. The process ends, and the BEAM logs a crash report with the panic message.
  4. The supervisor notices. The supervisor is linked to its child, so it learns of the exit at once and logs a supervisor report: errorContext child_terminated.
  5. Started again. The child is permanent, so the supervisor calls start(name) again: a new process, with 5 units, under the same name.
  6. The name still works. main's named subject now reaches the new process, and Left answers 5: the reservation of 2 units is gone with the old process.
  7. Too many crashes. give_up crashes the actor every 100 ms. The supervisor restarts it twice; the third crash within 5 seconds passes its default limit of 2 restarts.
  8. The supervisor stops. The supervisor shuts down with its children, and the exit signal reaches main, which is linked to it: the program ended with exit reason Shutdown.

One crash in full, then a supervisor that gives up Python · supervisor_session.py
from session import run

# A named stock actor under a one-for-one supervisor. supervised crashes it once, in full; give_up crashes it every
# 100 ms until the supervisor stops, and its output goes to give_up.log, of which the session shows the lines that say
# what happened (each crash, each report's errorContext, the supervisor's reason and how the program ended).
run(
    """
cd processes
gleam run -m supervised
!gleam run -m give_up > give_up.log 2>&1
grep -E '^crash|errorContext|reached_max|exit reason|^  Shutdown' give_up.log
"""
)

Output

$ cd processes
$ gleam run -m supervised
Downloading packages
 Downloaded 4 packages in …s
  Compiling gleam_stdlib
  Compiling gleam_erlang
  Compiling gleam_otp
  Compiling gleeunit
  Compiling processes
   Compiled in …s
    Running supervised.main
src/supervised.gleam:49
Ok(3)
=CRASH REPORT==== … ===
  crasher:
    initial call: gleam@otp@actor:'-start/1-anonymous-0-'/0
    pid: <0.91.0>
    registered_name: 'stock$…'
    exception error: #{function => <<"handle">>,line => 28,
                       message => <<"the stock actor was told to crash">>,
                       module => <<"supervised">>,
                       file => <<"src/supervised.gleam">>,
                       gleam_error => panic}
      in function  supervised:handle/2 (src/supervised.gleam:31)
      in call from gleam@otp@actor:loop/1 (src/gleam/otp/actor.gleam:491)
    ancestors: [<0.90.0>,<0.89.0>]
    message_queue_len: 0
    messages: []
    links: [<0.90.0>]
    dictionary: []
    trap_exit: false
    status: running
    heap_size: 376
    stack_size: 29
    reductions: …
  neighbours:

=SUPERVISOR REPORT==== … ===
    supervisor: {<0.90.0>,gleam@otp@static_supervisor}
    errorContext: child_terminated
    reason: {#{function => <<"handle">>,line => 28,
               message => <<"the stock actor was told to crash">>,
               module => <<"supervised">>,file => <<"src/supervised.gleam">>,
               gleam_error => panic},
             [{supervised,handle,2,[{file,"src/supervised.gleam"},{line,31}]},
              {gleam@otp@actor,loop,1,
                               [{file,"src/gleam/otp/actor.gleam"},
                                {line,491}]},
              {proc_lib,init_p,3,[{file,"proc_lib.erl"},{line,317}]}]}
    offender: [{pid,<0.91.0>},
               {id,0},
               {mfargs,
                   {gleam@otp@static_supervisor,start_child_callback,
                       [#Fun<gleam@otp@supervision.0.84844907>]}},
               {restart_type,permanent},
               {significant,false},
               {shutdown,5000},
               {child_type,worker}]

after the restart:
src/supervised.gleam:53
5
$ gleam run -m give_up > give_up.log 2>&1
[exit status 1]
$ grep -E '^crash|errorContext|reached_max|exit reason|^  Shutdown' give_up.log
crash 1
    errorContext: child_terminated
crash 2
    errorContext: child_terminated
crash 3
    errorContext: child_terminated
    errorContext: shutdown
    reason: reached_max_restart_intensity
exit reason:
  Shutdown

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 supervisor_session.py

The crash printed two reports. The crash report names the process, its registered name and the panic’s message, file and line; the supervisor report says errorContext: child_terminated and lists the child’s settings, among them restart_type,permanent, the default of supervision.worker. After the restart the named subject reached the new process, and Left answered 5, not 3: the reservation lived in the old process’s state and died with it.

give_up shows the limit. The supervisor restarted the actor after the first and the second crash, and after the third it stopped with reached_max_restart_intensity: the documentation of static_supervisor sets the default restart intensity: The limit on how often a supervisor restarts its children: more than that many restarts within a period makes it shut down its children and itself. gleam_otp's static supervisor allows 2 restarts in 5 seconds by default. to 2 restarts in 5 seconds, and more than that makes the supervisor shut down its children and itself. main was linked to it, so the program ended too, with the exit reason Shutdown. A supervisor stops a crash loop instead of hiding it.

State that must survive a crash

A restart begins from the actor’s starting state, so anything the business cannot lose, such as a confirmed booking or a payment, must be written somewhere that outlives the process before the actor replies: a database, or a log the new actor reads back when it starts.

Checkpoint

Check: Supervisors and restarts

  1. Question 1 of 2 The supervised stock actor had 3 units left when it crashed. What did Left answer after the restart, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 5, because the supervisor started a new actor from its starting state

    A restart gives a fresh process with the starting state. Whatever must survive a crash is written somewhere the new process can read it back.

  2. Question 2 of 2 With the default settings of gleam_otp's static supervisor, how many restarts within 5 seconds does it make before it gives up on the next crash?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 2 restarts

    The default intensity is 2 in a period of 5 seconds. In give_up, the actor was restarted after the first and the second crash, and the third crash made the supervisor shut down with reached_max_restart_intensity.

@external calls Erlang or JavaScript, and the compiler trusts its type

Gleam reaches code written in other languages through external function: A Gleam function with no Gleam body and an @external attribute for each target, naming the Erlang module or JavaScript file and the function that implement it. The compiler trusts the type written on it.: a function with no Gleam body and one @external attribute for each target, naming the module and function that implement it. The language tour adds the rule that matters most: the compiler has no way to read the types of code in another language, so it takes the type you declare on trust, and a wrong one can crash at run time. ffi.gleam binds runtime() to a small Erlang module and a JavaScript file in its own src folder:

One Gleam function, two implementations

ffi/src/ffi.gleam

import gleam/int
import gleam/io
import gleam/string

/// The runtime this code runs on. The compiler cannot read Erlang or
/// JavaScript, so it trusts the type written here: String.
@external(erlang, "ffi_ffi", "runtime")
@external(javascript, "./ffi_ffi.mjs", "runtime")
pub fn runtime() -> String

pub fn main() -> Nil {
  let name = runtime()
  io.println("running on " <> name)
  io.println(
    "the name has " <> int.to_string(string.length(name)) <> " characters",
  )
}

ffi/src/ffi_ffi.erl

-module(ffi_ffi).
-export([runtime/0]).

%% Returns a binary, which is what a Gleam String is on the BEAM.
runtime() ->
    Release = list_to_binary(erlang:system_info(otp_release)),
    <<"Erlang/OTP ", Release/binary>>.

ffi/src/ffi_ffi.mjs

// Returns a JavaScript string, which is what a Gleam String is on JavaScript.
export function runtime() {
  return `Node.js ${process.versions.node}`;
}

wrong_type.gleam gets it wrong on purpose: erlang:length/1 counts the items of a list, and the binding says it counts a String:

wrong_type.gleam: a binding with the wrong type GLEAM · wrong_type/src/wrong_type.gleam
import gleam/int
import gleam/io

/// erlang:length/1 counts the items of a list. The type written here says it
/// counts a String, and the compiler cannot tell that this is wrong.
@external(erlang, "erlang", "length")
fn count(text: String) -> Int

pub fn main() -> Nil {
  io.println("calling erlang:length on a Gleam String")
  io.println(int.to_string(count("Gleam")))
}

Step through both projects, then run them:

Interactive · step by step One Gleam function, an implementation on each target Step through the two builds of runtime(), then the binding whose type is wrong.
One Gleam function, an implementation on each target, step 6 of 6. Showing runtime() -> String, The type checker, ffi_ffi:runtime/0, ffi_ffi.mjs runtime(), "Erlang/OTP 29", "Node.js 24.21.0". In focus: runtime() -> String.@external(erlang)@external(javascript)runtime() -> StringThe type checkerffi_ffi:runtime/0ffi_ffi.mjs runtime()"Erlang/OTP 29""Node.js 24.21.0"erlang:error(Badarg)

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A function without a body. runtime() has a type, -> String, and no Gleam body. Its two @external lines name the Erlang module and function, and the JavaScript file and export, that implement it.
  2. The type is trusted. The type checker cannot read Erlang or JavaScript, so it takes -> String on trust and checks every Gleam caller against it.
  3. On the BEAM. The Erlang build calls ffi_ffi:runtime/0, which builds a binary, the BEAM's form of a Gleam String: running on Erlang/OTP 29, 13 characters.
  4. On Node.js. The JavaScript build imports runtime from ffi_ffi.mjs, which returns a JavaScript string: running on Node.js 24.21.0, 15 characters.
  5. A wrong type compiles. wrong_type.gleam binds erlang:length/1, which counts the items of a list, as fn(String) -> Int. The Gleam compiler accepts it, because it trusts the type.
  6. It fails at run time. Called with "Gleam", a binary, erlang:length raised badarg and the program stopped with exit status 1. Erlang's own compiler warned, but only because the argument was a literal.

runtime() on both targets, the standard library's own bindings, and a wrong type Python · ffi_session.py
from session import run

# One Gleam function with an external for each target, run on both; how the standard library binds int.to_string
# the same way; then a binding whose type is wrong, which the Gleam compiler accepts.
run(
    """
cd ffi
gleam run
gleam run --target javascript
grep -n -B 2 '^pub fn to_string' build/packages/gleam_stdlib/src/gleam/int.gleam
cd ../wrong_type
!gleam run
"""
)

Output

$ cd ffi
$ gleam run
Downloading packages
 Downloaded 2 packages in …s
  Compiling gleam_stdlib
  Compiling gleeunit
  Compiling ffi
   Compiled in …s
    Running ffi.main
running on Erlang/OTP 29
the name has 13 characters
$ gleam run --target javascript
  Compiling gleam_stdlib
  Compiling gleeunit
  Compiling ffi
   Compiled in …s
    Running ffi.main
running on Node.js 24.21.0
the name has 15 characters
$ grep -n -B 2 '^pub fn to_string' build/packages/gleam_stdlib/src/gleam/int.gleam
147-@external(erlang, "erlang", "integer_to_binary")
148-@external(javascript, "../gleam_stdlib.mjs", "to_string")
149:pub fn to_string(x: Int) -> String
$ cd ../wrong_type
$ gleam run
Downloading packages
 Downloaded 2 packages in …s
  Compiling gleam_stdlib
  Compiling gleeunit
  Compiling wrong_type
src/wrong_type.gleam:13:51: Warning: the call to length/1 will fail with a 'badarg' exception
   Compiled in …s
    Running wrong_type.main
calling erlang:length on a Gleam String
runtime error: Erlang error

An error occurred outside of Gleam.

erlang:error(Badarg)

stacktrace:
  erlang.length unknown source
  wrong_type.main src/wrong_type.gleam:13
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ffi_session.py

On the BEAM runtime() returned Erlang/OTP 29, a binary built by the Erlang function, and on Node.js Node.js 24.21.0, a JavaScript string from process.versions.node; string.length worked on both because each implementation returned what a Gleam String is on its target. The standard library binds its own functions the same way: int.to_string has no Gleam body, only erlang:integer_to_binary and a function of gleam_stdlib.mjs.

The wrong binding compiled without a word from Gleam. Erlang’s own compiler warned that the call would fail with badarg, only because the argument was a literal it could see, and at run time the program stopped with erlang:error(Badarg) and exit status 1. With a value from anywhere else, nothing would have warned.

Binding a foreign function by reading its name only

Read the foreign function’s documentation for every argument and the return value, as Erlang’s documentation of length/1 says it takes a list. When the types do not line up, write a small .erl or .mjs wrapper that converts them, as ffi_ffi.erl turns a charlist into a binary, and test the binding on each target it claims.

Checkpoint

Check: External functions on each target

  1. Question 1 of 2 Why did wrong_type.gleam compile, although erlang:length/1 does not count strings?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The Gleam compiler cannot read Erlang, so it trusts the type written on the @external function

    An external is a promise you make to the compiler. Read the foreign function's documentation, and wrap it when its types do not match Gleam's.

  2. Question 2 of 2 runtime() has an @external for Erlang and one for JavaScript. What did string.length(runtime()) give on each target?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 13 on the BEAM for "Erlang/OTP 29", 15 on Node.js for "Node.js 24.21.0"

    A multi-target external has one implementation per target, each returning what the Gleam type means there.

Practise: the handler of a rate-limiter actor

An actor is a loop around one function, the state and a message in, the next state and a reply out, so its logic can be written and tested without any process at all. The exercise writes that function for a rate limiter, three requests per user in each 1,000 ms window, in Python so that its tests run in your browser; the tests send it scripted message sequences the way a mailbox would, including the edge at 1,000 ms and a state that must not change in place:

Exercise · Medium · Python

The message handler of a rate-limiter actor

An actor is a loop around one function: the state and a message in, the next state and a reply out. Write that function for a rate limiter, in Python so that its tests run in your browser: handle(state, message) in limiter.py returns (new_state, reply), where the state is a dict and must not be changed in place, so that the caller can keep the old one, as an actor's actor.continue(next) does.

  • ("request", user, at), with at in whole milliseconds, asks whether user may make a request now. Time is cut into fixed windows of WINDOW = 1000 ms, numbered at // WINDOW: 0 to 999 is window 0, 1000 to 1999 window 1. A user may make LIMIT = 3 requests in one window. The reply is True for an allowed request, which counts, and False for a refused one, which does not.
  • ("forget", user) removes the user from the state, as a reset by a support desk would; the reply is None.
  • Users are counted apart, and the state starts as {}. Times of one user never go backwards.

Keep for each user only their window number and their count in it, so that the state does not grow with the number of requests.

Starter code · limiter.py

LIMIT = 3
WINDOW = 1000


def handle(state, message):
    """One message to the rate limiter: returns (new_state, reply)."""
    # Replace this with your code: it allows every request and never counts.
    if message[0] == "request":
        return (state, True)
    return (state, None)
The sample tests · test_limiter.py
from limiter import handle


def run(messages, state=None):
    """Sends the messages one after another, as an actor's mailbox would, and returns the replies."""
    state = {} if state is None else state
    replies = []
    for message in messages:
        state, reply = handle(state, message)
        replies.append(reply)
    return replies


def test_three_requests_then_a_refusal():
    """the fourth request of a window is refused"""
    assert run([("request", "asha", t) for t in (10, 20, 30, 40)]) == [True, True, True, False]


def test_the_next_window_starts_again():
    """1000 ms is the first instant of window 1"""
    assert run([("request", "asha", t) for t in (0, 1, 2, 999, 1000)]) == [True, True, True, False, True]


def test_users_are_counted_apart():
    """ravi's requests do not use asha's"""
    replies = run([("request", "asha", 5)] * 3 + [("request", "ravi", 6), ("request", "asha", 7)])
    assert replies == [True, True, True, True, False]


def test_refused_requests_do_not_count():
    """after a refusal in window 0, window 1 still allows three"""
    messages = [("request", "meera", t) for t in (100, 200, 300, 400, 1100, 1200, 1300, 1400)]
    assert run(messages) == [True, True, True, False, True, True, True, False]


def test_forget_resets_a_user():
    """a forgotten user starts again in the same window"""
    messages = [("request", "joe", t) for t in (1, 2, 3)] + [("forget", "joe"), ("request", "joe", 4)]
    assert run(messages) == [True, True, True, None, True]


def test_the_old_state_is_not_changed():
    """an actor keeps the old state until handle returns the new one"""
    before = {"asha": (0, 2)}
    after, reply = handle(before, ("request", "asha", 500))
    assert (reply, before, after) == (True, {"asha": (0, 2)}, {"asha": (0, 3)})
A hint

Look up the user's (window, count). If the user is new, or their window is not the current one, the count starts again at 0. Then compare the count with LIMIT, and build the new state with {**state, user: (window, count + 1)} only when the request is allowed.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then the self-check: it begins with whichever of the four ideas you are shakiest on, from subjects to externals, and every option you choose comes with its reason:

Practice that adapts to you

Practise this lesson

8 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 What does process.spawn do with the function it is given?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: Runs it in a new process, beside the process that called spawn

    A spawned process is linked to its creator: if one of them crashes, the exit signal reaches the other. Use spawn_unlinked when that is not wanted.

  2. Question 2 of 8 counter.gleam sorts the squares before it prints them. What would printing them in the order received risk?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: An order that can change from run to run, because the processes run concurrently

    Never let output depend on the scheduling of processes: sort, sum or key the results, as the program does.

  3. Question 3 of 8 Why does the Reserve message carry a reply field of type Subject(Result(Int, String))?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: So that the actor can send its answer to the process that asked

    Messages that need an answer carry the subject to answer on; messages that do not, like Release, are sent with process.send and return at once.

  4. Question 4 of 8 A teammate splits Reserve into two messages: HowMany, which answers the units left, and Take, which subtracts without checking. Buyers send HowMany, then Take if enough remain. What can go wrong?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: Two buyers can both hear 1 left before either Take arrives, and the stock goes to -1

    Put the decision and the change in one message, as Reserve does: the actor serialises messages, not conversations.

  5. Question 5 of 8 What does the OneForOne strategy of a supervisor do when one child ends with a crash?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: Restarts that child only, and leaves its siblings running

    OneForOne suits children that do not depend on each other, such as one actor per show.

  6. Question 6 of 8 An actor crashes on every message because a value it reads at start is missing. What does its supervisor do with the default settings?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: Restarts it twice, then shuts down with reached_max_restart_intensity, and the exit reaches the linked parent

    The restart limit turns a crash loop into a visible failure higher up the tree. Fix the cause; a larger limit only hides it longer.

  7. Question 7 of 8 Which declaration binds a Gleam function to an Erlang function and a JavaScript function?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: Two attributes, @external(erlang, "module", "function") and @external(javascript, "./file.mjs", "function"), on a function with a type and no body

    The type annotation is required, because the compiler cannot work it out from the foreign code.

  8. Question 8 of 8 erlang:system_info(otp_release) returns a charlist, a list of character codes. How do you expose it to Gleam as a String?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: Write an Erlang wrapper that converts it with list_to_binary, and bind the wrapper

    At an external boundary, make the foreign value match the Gleam type in a few lines of Erlang or JavaScript, and test it on each target.

Interview questions

Warm-up (fresher to mid-level): What is a subject in Gleam, and why does Gleam send messages through one instead of to a process id?

A subject is a value through which processes send each other messages of one type: Subject(Int) carries Ints, and the compiler refuses a message of any other type. It belongs to the process that created it: any process may send to it, and only that owner may receive from it, with process.receive(subject, within: ms), which returns Error(Nil) when the timeout passes. A plain Erlang process id accepts any message at all, so a mistake shows up as a message nobody handles; a subject puts the message type in the type system. In the lesson, main made one subject, five processes sent squares to it and main summed them to 55. A named subject goes further: it reaches whichever process holds a name, which is how callers kept talking to the stock actor after its restart.

Two longer questions, one about lost state and one about a binding:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to staff

    A Gleam service keeps each shopping cart in an actor under a supervisor. After a bad deploy, users report empty carts that nobody cleared, and the logs show crash reports for cart actors. Explain what happened, and redesign it so that a crash never loses what a customer added.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A Gleam service binds an Erlang library function with @external, typed fn(String) -> Int. Its tests pass, but in production some requests crash with erlang:error(Badarg). The Gleam compiler never warned. Why, how do you find the bad input, and how do you fix the binding for good?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What is a process on the BEAM?

    A lightweight unit of execution with its own memory and its own mailbox. Processes share no data and cooperate by sending messages; in Gleam, process.spawn starts one.

  2. Who may send to a Gleam subject, and who may receive from it?

    Any process may send to it, but only the process that created it may receive; receive panics anywhere else. Create the subject in the process that waits for the answers.

  3. What does process.receive(results, within: 100) give when no message arrives?

    Error(Nil), after 100 ms. A receive with a timeout is a Result, so a process never waits forever by accident.

  4. Why did the program sort the five squares before printing them?

    Messages reach the mailbox in whatever order the processes happened to run, so output that depended on that order would change from run to run.

  5. Why could two buyers never both take the last unit from the stock actor?

    The actor takes its messages one by one, each against the state the previous one left, so a check and an update in one handler call are never split. 4 of 10 buyers got the 4 units, with no lock.

  6. A supervised stock actor crashes after reserving 2 of 5 units. What does Left answer after the restart?

    5. A supervisor restarts a child from its starting state, so the reservation died with the old process. State that must survive goes to a database or a log first.

  7. When does a gleam_otp static supervisor stop restarting a crashing child?

    After more than 2 restarts within 5 seconds, its default restart intensity. It then ends with reached_max_restart_intensity, shuts down its children and takes linked callers down too.

  8. What does the Gleam compiler check about the type of an @external function?

    Nothing: it cannot read the foreign code, so it trusts the declared type. Binding erlang:length/1 to a String compiled and then stopped at run time with badarg.

Key takeaways

  • process.spawn starts a process beside the current one, a subject carries typed messages to the process that owns it, and process.receive with a timeout returns Error(Nil) instead of waiting forever.
  • A gleam_otp actor is a typed message loop: a handler takes the state and one message and returns the next state, so a check and an update in one message cannot be interleaved, and ten buyers got only the four units there were.
  • A supervisor restarts a crashed child from its starting state, by the same name, and gives up after more than 2 restarts in 5 seconds by default, taking its linked callers down with it.
  • An @external function names an implementation per target, and the compiler trusts its type: a wrong type compiles and fails at run time, so wrap foreign functions whose types do not match and test each target.

Terms in this lesson

actor (gleam_otp)
A process that keeps state and handles the messages of one type one at a time: a handler takes the current state and a message and returns the next state. gleam_otp's actor module provides it, with a typed interface.
external function
A Gleam function with no Gleam body and an @external attribute for each target, naming the Erlang module or JavaScript file and the function that implement it. The compiler trusts the type written on it.
mailbox
The queue of messages that a BEAM process has received and not yet handled. Messages from one sender arrive in the order they were sent; messages from several senders have no fixed order.
process (BEAM)
A lightweight unit of execution on the BEAM, with its own memory and its own mailbox. Processes share nothing and work together by sending each other messages; in Gleam, process.spawn from gleam_erlang starts one.
restart intensity
The limit on how often a supervisor restarts its children: more than that many restarts within a period makes it shut down its children and itself. gleam_otp's static supervisor allows 2 restarts in 5 seconds by default.
subject (Gleam)
A value through which processes send messages of one type, such as Subject(Int). It is owned by the process that created it: any process may send to it, and only the owner may receive from it.
supervisor
A process that starts other processes, its children, and starts a child again when it ends with a crash, by calling its start function, so the new child begins from its starting state.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.