Back to Blog

Learning Gleam Concurrency: A TypeScript Developer's Journey

October 21, 2025 · 3 min read · Gleam · Concurrency · TypeScript · Functional Programming · Learning

I wrote async/await TypeScript for years before I tried Gleam. Its concurrency model is different enough that it took me a while to stop thinking in promises. These are two problems I solved in both languages, and what the actor model changed for me.

The TypeScript way

In TypeScript, concurrent work is orchestrated with async/await and Promise. State is shared across async boundaries, which means you think about who can mutate what, and when:

// TypeScript: fetch a user and their posts and comments
async function fetchUserData(userId: string): Promise {
  const user = await fetchUser(userId);
  const posts = await fetchUserPosts(userId);
  const comments = await fetchUserComments(userId);

  return {
    ...user,
    posts,
    comments
  };
}

// What if one of these fails?
// What if we want to cancel the whole operation?
// What if we need to share state between these calls?

Gleam takes a different route: actors. An actor is a lightweight process that owns its state and communicates only by sending and receiving messages. No shared state, no locks, no race conditions — the only way in or out is a message.

Example 1: a chat system

A chat room tracks users and messages and broadcasts new messages to everyone. In TypeScript that means a mutable collection plus care around concurrent updates and broadcast failures:

// TypeScript: managing state and async operations
class ChatRoom {
  private messages: Message[] = [];
  private users: Set = new Set();

  async addMessage(userId: string, content: string): Promise {
    // What if another message is being added at the same time?
    // What if the user gets disconnected while we're processing?
    const message: Message = {
      id: crypto.randomUUID(),
      userId,
      content,
      timestamp: Date.now()
    };

    this.messages.push(message);
    await this.broadcastToUsers(message);
  }

  private async broadcastToUsers(message: Message): Promise {
    // Async broadcasting - what if this fails?
    const promises = Array.from(this.users).map(userId =>
      this.sendToUser(userId, message)
    );
    await Promise.all(promises);
  }
}

Every method that touches the room has to handle the same questions: ordering of concurrent updates, failures mid-broadcast, who owns the state.

Gleam approach

// Gleam: actor-based chat system
import gleam/io
import gleam/result

// Define our message types
pub type ChatMessage {
  UserMessage(user_id: String, content: String)
  JoinRoom(user_id: String)
  LeaveRoom(user_id: String)
}

pub type ChatRoom {
  ChatRoom(messages: List(String), users: List(String))
}

// Our chat room actor
pub fn chat_room_loop(state: ChatRoom) -> Nil {
  case receive() {
    UserMessage(user_id, content) -> {
      let message = "User " <> user_id <> ": " <> content
      let new_messages = [message, ..state.messages]
      let new_state = ChatRoom(new_messages, state.users)

      // Broadcast to all users
      broadcast_message(message, state.users)
      chat_room_loop(new_state)
    }
    JoinRoom(user_id) -> {
      let new_users = [user_id, ..state.users]
      let new_state = ChatRoom(state.messages, new_users)
      chat_room_loop(new_state)
    }
    LeaveRoom(user_id) -> {
      let new_users = list.filter(state.users, fn(u) { u != user_id })
      let new_state = ChatRoom(state.messages, new_users)
      chat_room_loop(new_state)
    }
  }
}

fn broadcast_message(message: String, users: List(String)) -> Nil {
  // Each user gets their own actor to handle messages
  list.foreach(users, fn(user_id) {
    send(user_id, message)
  })
}

The room is a loop that owns the state and changes it only through messages. There is nothing to lock, because no other process can see the state at all.

Example 2: a game score system

A multiplayer game needs concurrent score updates without losing points. In TypeScript, shared mutable state means a lock around every access:

// TypeScript: managing concurrent score updates
class GameScore {
  private scores: Map = new Map();
  private lock = new Mutex(); // Need to prevent race conditions!

  async addScore(playerId: string, points: number): Promise {
    await this.lock.acquire();
    try {
      const currentScore = this.scores.get(playerId) || 0;
      this.scores.set(playerId, currentScore + points);
    } finally {
      this.lock.release();
    }
  }

  async getTopPlayers(limit: number): Promise {
    await this.lock.acquire();
    try {
      return Array.from(this.scores.entries())
        .map(([playerId, score]) => ({ playerId, score }))
        .sort((a, b) => b.score - a.score)
        .slice(0, limit);
    } finally {
      this.lock.release();
    }
  }
}

The lock makes the updates safe, but it is bookkeeping you have to get right on every method that touches the scores.

Gleam approach

// Gleam: actor-based score system
import gleam/list
import gleam/string

pub type ScoreMessage {
  AddScore(player_id: String, points: Int)
  GetTopPlayers(limit: Int, reply_to: Pid)
  GetPlayerScore(player_id: String, reply_to: Pid)
}

pub type ScoreState {
  ScoreState(scores: List(#(String, Int)))
}

pub fn score_actor_loop(state: ScoreState) -> Nil {
  case receive() {
    AddScore(player_id, points) -> {
      let new_scores = update_player_score(state.scores, player_id, points)
      let new_state = ScoreState(new_scores)
      score_actor_loop(new_state)
    }
    GetTopPlayers(limit, reply_to) -> {
      let top_players = get_top_players(state.scores, limit)
      send(reply_to, top_players)
      score_actor_loop(state)
    }
    GetPlayerScore(player_id, reply_to) -> {
      let player_score = get_player_score(state.scores, player_id)
      send(reply_to, player_score)
      score_actor_loop(state)
    }
  }
}

fn update_player_score(scores: List(#(String, Int)), player_id: String, points: Int) -> List(#(String, Int)) {
  // Update or add player score
  case list.find(scores, fn(score) { score.0 == player_id }) {
    Ok((_, current_points)) -> {
      let new_score = #(player_id, current_points + points)
      list.replace(scores, #(player_id, current_points), new_score)
    }
    Error(_) -> {
      // Player not found, add new score
      [#(player_id, points), ..scores]
    }
  }
}

The score state belongs to one actor. Updates arrive as messages and are applied in order, so no lock is needed. Queries are messages too, with a reply address attached.

What the actor model changes

  • No shared state: two actors cannot touch the same data at the same time, because there is no shared data to touch.
  • Fault isolation: a crashed actor does not bring down the rest of the program; other actors keep running.
  • Testability: an actor is exercised by sending it messages and checking the replies, no mocking of shared state.
  • No nesting: no chains of await and Promise.all; interaction is a flat loop of receive and send.

When to use which

Async/await in TypeScript is the right tool when you are integrating with the JavaScript ecosystem, building a straightforward CRUD application, or working with a team that thinks in imperative code. Gleam actors fit when the system is highly concurrent, needs fault tolerance, or handles real-time data — anywhere shared mutable state is the main source of bugs.

Neither model is better in general. But most concurrent systems I have built are naturally described as message flows, which is exactly what actors model. If you come from TypeScript, that is a good reason to try Gleam.