Station 06Open the adventure map
00Set up your toolsSetup01The starting lineSetup02Processes and mailboxesR1An Elixir data pipelineOptional reviewR2Read ErlangOptional review03Messages and timeouts04OTP rules for messages05A supervision tree that restarts06Put a limit on concurrency07When a BEAM node disappearsX1Agree on shared BEAM termsBilingual extensionX2Two-language partnersBilingual extension08A reliable job runner
Home/BEAM mainline/Station 06
06
CapacityCapacity challengeElixirErlangBEAM

Put a limit on concurrency

Use a plain function for plain calculation. When work needs concurrency, draw a line around the task count.

3checkpoints
About 5 hours · Try 3 sessionssplit it into sessions
QUESTION · ONE PROBLEM FOR THIS STATION

When ten thousand URL checks arrive in a burst, how do we limit running and waiting work while returning timeout, failure, or busy clearly?

Start at the scene

Unlimited Tasks consumed every connection while the mailbox kept growing

The system starts a Task for every URL immediately. Connections and memory are soon exhausted, but requests keep queuing invisibly and callers can only wait for timeout.

Evidence you can observe
  • Peak active Task and connection counts
  • Queue or mailbox length together with P95 latency
  • Separate counts for timeout, execution failure, and capacity rejection
Why this station matters

The problem it solves

Processes are good for owning state, expressing a lifetime, and isolating errors. Plain calculations do not all need GenServer. Unlimited Tasks can exhaust connections and memory. Choose tools by state and capacity.

After this station

You will be able to

  • Choose a plain function, process state, or ETS by asking whether state is long-lived and who must read or write it

  • Limit the number of running tasks and handle timeouts and a waiting queue

  • Use mailbox length, wait time, and rejection rate to notice when a system cannot keep up

Before you start
  • Know how a GenServer owns state and how a supervision tree watches processes
  • Run at least one small independent task with Task
One job, two ways to write it

See what it does before how it is written

Elixir sets the concurrency limit directly. The Erlang outline shows monitors, a running set, and a waiting queue.

Current code
Elixir
# Check URLs concurrently without passing the chosen limit
urls
|> Task.async_stream(
  &check_url/1,
  # Set the concurrency limit from the scheduler count
  max_concurrency: System.schedulers_online() * 2,
  timeout: 3_000,
  on_timeout: :kill_task,
  ordered: false
)
# Gather each result into success and failure counts
|> Enum.reduce(%{ok: 0, error: 0}, &count_result/2)
DESIGN DECISION

Why this shape?

Why bound both running work and waiting work?

Choice

A concurrency limit protects active resources. A queue limit stops waiting work from quietly consuming all memory. At capacity, the service must admit it is busy.

Cost and boundary

async_stream limits running tasks; it is not a complete durable bounded queue. Admission still needs its own design.

A FAMILIAR POINT OF VIEW

Coming from Java, Python, or JavaScript

Java

Familiar starting point
Bounded executors, Semaphore, and BlockingQueue.
What BEAM changes
BEAM still needs limits for workers, mailboxes, or waiting queues.
False friend
Cheap virtual threads do not create infinite database connections.

Python

Familiar starting point
asyncio.Semaphore, Queue, and worker pools.
What BEAM changes
Messages are cheap, but a mailbox has no natural capacity limit.
False friend
Coroutine count and external-resource capacity are different numbers.

JavaScript

Familiar starting point
Promise batches, p-limit, or stream backpressure.
What BEAM changes
spawn or cast lets the caller move on but may transfer waiting into a mailbox.
False friend
Asynchronous is not the same as bounded.
LAB
Hands on

Does one plus one need a GenServer?

About 15–25 minutes

Run the same independent calculations two ways: calls to one GenServer and direct function calls. Compare time and mailbox length.

  1. 01

    Build a Calculator GenServer that does only a small CPU calculation and owns no long-lived state

  2. 02

    Call the one server from many Tasks and record total time and mailbox length

  3. 03

    Change the calculation to a plain function and measure the same inputs again

Copy into the terminal and press Enter
# Run the workload and return elapsed microseconds with its result
:timer.tc(fn -> workload.() end)
What you should see
  • One server makes otherwise independent calculations wait in one line
  • Plain functions can run inside each caller, making it easier for several schedulers to work together
Break it on purpose

Set max_concurrency to the full input count and make every task hold one connection. Record the resource peak.

What this shows

Independent calculations with no shared state usually do not need to squeeze through one process.

What this does not show yet

This does not mean every GenServer is slow. Its main jobs are state, lifetime, and message agreements.

Meet the words

Key ideas in this code

01

Backpressure

A signal sent upstream when downstream has too little capacity. Upstream can wait, slow down, be rejected, or enter a bounded queue.

02

ETS

A BEAM table that many processes can read and write quickly. It still has an owner, and by default the table disappears when that owner exits.

03

Bounded concurrency

A fixed upper limit on running tasks, making CPU, connection, and memory use easier to control.

Name the shape in the code

Design patterns used here

01

Bulkhead

Isolate and cap concurrent workers and external connections.

02

Bounded Buffer

Limit the waiting queue instead of letting work pile up invisibly.

03

Backpressure

Send capacity information upstream through waiting, slowing, or rejection.

Think it through

Which job least needs a GenServer?

Your turn

Build a bounded URL checker

Check a group of URLs while limiting concurrency, timeouts, and waiting items. Report success rate, P95 duration, and rejection count.

Hint 1Take the first step

Write down the largest number of running and waiting tasks before choosing an API.

Hint 2Make it a little smaller

Count “waited too long” separately from an HTTP error. They are not the same failure.

Hint 3You are close

ordered: false lets finished tasks return results without waiting for the slowest earlier task.

Hint 4Work backward from the finish

Choose one success signal and write the smallest test for it. If the computer cannot show the result, rewrite the signal as something you can truly observe.

Ready to move on when
  • Maximum concurrency and maximum waiting count can both be changed

  • A sudden input spike cannot grow memory forever through an unlimited queue

  • Results separate ordinary failure, timeout, and rejection because the service was busy

Take these with you

Remember three things

  1. 1

    A process expresses who owns state, how long it lives, and where an error should stop.

  2. 2

    Asynchronous means the caller need not wait here. It does not mean the task count is bounded.

  3. 3

    Backpressure makes “too busy” visible and controls it through waiting, slowing, rejection, or degraded service.

Read a little more

Visit the original sources

Task.async_streamETS User's Guide
Station completeYou ran the experiment and thought through the answer. Save this station.