DEV Community

Matheus de Camargo Marques
Matheus de Camargo Marques

Posted on

A SEDA Web Server in Erlang: When an Architecture Paper Becomes a Teaching Exercise

A SEDA Web Server in Erlang: When an Architecture Paper Becomes a Teaching Exercise

Traditional web servers based on synchronous TCP connections hit a wall at scale. Each connection consumes an OS thread, each thread reserves megabytes of stack, and the context-switching overhead grows until the server spends more time managing threads than serving requests. The usual escape hatch — distributing across clusters — adds cost and operational complexity without solving the underlying problem.

The ERLDA paper by Fernando Areias and Adolfo Neto, presented at the I Workshop em Engenharia de Software para Programação Funcional (SE4FP 2025), argues that Erlang’s BEAM virtual machine and the SEDA (Staged Event-Driven Architecture) model are a natural fit for this problem. But more interesting than the argument is the exercise it suggests: implement a minimal HTTP server with three SEDA stages under OTP supervision.

That exercise is a masterclass in architectural thinking. It forces the student to confront the same design decisions that production systems face — queue backpressure, stage isolation, failure recovery — in a codebase small enough to reason about completely. This article walks through that exercise, explains why Erlang maps to SEDA so cleanly, and connects it to the broader arc of learning to build verifiable concurrent systems.


What SEDA actually proposes

SEDA was introduced by Welsh, Culler, and Brewer in 2001 as a response to the limitations of both thread-per-request and pure event-driven servers. Its core idea is simple:

Decompose the application into stages connected by explicit event queues. Each stage performs a subset of request processing and is executed by a pool of threads.

A stage has three components:

  • An incoming event queue.
  • A thread pool that drives execution.
  • An application-supplied event handler that processes events.

The queues are not an implementation detail. They are the control boundary that lets each stage be managed independently. If the processor stage becomes a bottleneck, its queue grows, and the system can shed load, throttle, or scale that stage alone — without touching the acceptor or the writer.

SEDA was designed for Java, but its abstractions are language-agnostic. The question the ERLDA paper asks is: what happens when you implement SEDA in a language where processes are cheap, mailboxes are queues, and supervision is a language primitive?


Why Erlang is the natural substrate for SEDA

The mapping between SEDA and Erlang/OTP is almost embarrassingly direct:

SEDA concept Erlang equivalent
Stage A process (or pool of processes)
Event queue A process mailbox
Thread pool A poolboy pool or a supervisor-managed set of workers
Event handler A gen_server callback
Queue backpressure Selective receive, bounded mailboxes, or explicit queue processes
Stage isolation Process isolation (separate heaps, separate failure domains)
Stage recovery OTP supervisor

This is not a coincidence. Erlang’s actor model is a staged architecture by default: each process has an isolated mailbox, processes communicate by asynchronous message passing, and failures are contained to the process that crashes. SEDA formalizes what Erlang already does natively.

The ERLDA paper makes the memory argument explicit: the BEAM VM is designed to support millions of simultaneous processes with efficient resource usage, prioritizing vertical scalability and operational simplicity over cluster-based horizontal scaling. A SEDA stage implemented as a pool of Erlang processes costs kilobytes per process, not megabytes per thread. The architectural model and the runtime substrate are aligned.


The three-stage exercise

The exercise proposed in the ERLDA discussion — and the one I want to walk through here — is a minimal HTTP server with three stages:

[ Acceptor ] --> [ Request queue ] --> [ Processor pool ] --> [ Response queue ] --> [ Writer ]
Enter fullscreen mode Exit fullscreen mode

Each arrow is a message-passing boundary. Each stage is a separate process or pool. OTP supervision wraps the whole thing so that the failure of any stage does not bring down the system.

Stage 1: The acceptor

The acceptor owns the listening socket. It accepts connections, reads the raw request bytes, and enqueues them for processing. It does not parse, does not validate, does not generate responses. Its only job is to move bytes from the network into the pipeline.

-module(acceptor).
-behaviour(gen_server).

start_link(Port) ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, Port, []).

init(Port) ->
    {ok, ListenSocket} = gen_tcp:listen(Port, [binary, {active, false}, {reuseaddr, true}]),
    self() ! accept,
    {ok, #{socket => ListenSocket}}.

handle_info(accept, #{socket := LS} = State) ->
    case gen_tcp:accept(LS) of
        {ok, Conn} ->
            {ok, Data} = gen_tcp:recv(Conn, 0, 5000),
            gen_server:cast(request_queue, {request, Data, Conn}),
            self() ! accept,
            {noreply, State};
        {error, _} ->
            self() ! accept,
            {noreply, State}
    end.
Enter fullscreen mode Exit fullscreen mode

The acceptor is a gen_server with a single job. If it crashes, the supervisor restarts it. The listening socket is recreated, and the server continues. No other stage is affected.

Stage 2: The request queue and processor pool

The request queue is a process that buffers incoming requests and distributes them to a pool of processor workers. In a minimal implementation, the queue itself can be a gen_server that holds a bounded list and dispatches to available workers:

-module(request_queue).
-behaviour(gen_server).

handle_cast({request, Data, Conn}, State) ->
    Worker = pick_worker(State),
    gen_server:cast(Worker, {process, Data, Conn}),
    {noreply, State}.
Enter fullscreen mode Exit fullscreen mode

The processor worker parses the HTTP request, generates a response (for the exercise, a simple JSON payload), and enqueues the result for the writer:

-module(processor).
-behaviour(gen_server).

handle_cast({process, Data, Conn}, State) ->
    Response = generate_response(Data),
    gen_server:cast(response_queue, {response, Response, Conn}),
    {noreply, State}.

generate_response(_Data) ->
    Body = <<"{\"status\":\"ok\"}">>,
    <<"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ",
      (integer_to_binary(byte_size(Body)))/binary, "\r\n\r\n", Body/binary>>.
Enter fullscreen mode Exit fullscreen mode

The processor pool is where the actual work happens. In a real system, this is where you would add CPU-bound processing, database queries, or calls to downstream services. In the exercise, it is deliberately trivial so that the architecture — not the business logic — is the focus.

Stage 3: The response queue and writer

The writer consumes responses from the response queue and sends them back to the client:

-module(writer).
-behaviour(gen_server).

handle_cast({response, Response, Conn}, State) ->
    gen_tcp:send(Conn, Response),
    gen_tcp:close(Conn),
    {noreply, State}.
Enter fullscreen mode Exit fullscreen mode

Again, one job. One process. One failure domain.

Supervision: the glue that makes it resilient

The entire pipeline is wrapped in an OTP supervision tree. The exact strategy depends on the desired semantics, but a one_for_one supervisor over the three stages is sufficient for the exercise:

init([]) ->
    SupFlags = #{strategy => one_for_one, intensity => 5, period => 10},
    Children = [
        #{id => acceptor, start => {acceptor, start_link, [8080]}},
        #{id => request_queue, start => {request_queue, start_link, []}},
        #{id => processor_sup, start => {processor_sup, start_link, []}},
        #{id => response_queue, start => {response_queue, start_link, []}},
        #{id => writer, start => {writer, start_link, []}}
    ],
    {ok, {SupFlags, Children}}.
Enter fullscreen mode Exit fullscreen mode

This is the payoff of the exercise. In Java, implementing SEDA requires a thread pool per stage, a bounded queue per stage, a rejection policy per queue, and a restart mechanism per stage — all wired together manually. In Erlang, the thread pool is a supervisor, the queue is a mailbox, and the restart mechanism is a supervisor callback. The architecture is the language.


What the exercise actually teaches

The three-stage server is small enough to implement in an afternoon, but it surfaces every decision that matters in a concurrent system:

1. Backpressure is explicit

If the processor pool cannot keep up with the acceptor, the request queue grows. In a real system, you bound the queue and define a rejection policy — {error, overload} or a 503 response. In the exercise, the student sees the queue as a first-class object that can grow, shrink, and overflow. The architecture forces the question: what happens when stage N is slower than stage N−1?

2. Failure isolation is structural

If the processor crashes on a malformed request, only that processor process dies. The acceptor continues accepting. The writer continues writing. The supervisor restarts the processor. The student sees that failure is contained by the topology, not by try/catch blocks scattered through the code.

3. Stages can be scaled independently

In the exercise, the processor pool is a supervisor with N children. To handle more load, increase N. The acceptor and writer remain unchanged. This is the SEDA promise made concrete: each stage is an independent scaling unit.

4. The queue is the control boundary

Because the queues are explicit, the student can see where load accumulates. In a thread-per-request server, there is no queue — there are just threads blocking on I/O. In SEDA, the queue is a named, observable, controllable thing. The architecture makes the system debuggable in a way that thread-per-request systems are not.


From exercise to verification: the next step

The three-stage server is a pedagogical milestone, but it is not the end of the learning arc. Once the student has built a SEDA system in Erlang, the natural next question is: how do I know this is correct?

This is where the verification literature connects. The same stage-and-queue topology that makes the system scalable also makes it verifiable:

  • ACTORCHESTRA can express cross-stage causal properties — “whenever the acceptor enqueues a request, the writer eventually sends a response” — and monitor them at runtime.
  • DDMon can detect deadlocks that emerge from synchronous calls between stages, proving soundness and completeness from the outside.
  • NVLang can enforce protocol conformance at compile time, ensuring that the processor never sends a message the writer cannot handle.

The SEDA exercise is the construction phase. Verification is the assurance phase. Staroletov’s pedagogical arc — refactoring, recursion, processes, supervision, verification — culminates in exactly this: a system that is not only built but provably correct.


Conclusion: architecture as a teaching tool

The ERLDA paper’s contribution is not a novel web server. It is a demonstration that SEDA and Erlang/OTP are conceptually isomorphic — and that this isomorphism can be taught through a small, complete, buildable exercise.

The three-stage HTTP server forces the student to think in terms of queues, stages, failure domains, and supervision. It makes backpressure visible, isolation structural, and scaling explicit. And when the student finishes, they have not just written a server — they have internalized an architecture that applies to every concurrent system they will build afterward.

That is what the best teaching exercises do: they are small enough to finish, and large enough to contain the whole problem.

References:

  • ERLDA: Areias, Fernando; Neto, Adolfo. “ERLDA: Explorando Concorrência e Resiliência com SEDA em Erlang.” In: I Workshop em Engenharia de Software para Programação Funcional (SE4FP 2025), Recife/PE, pp. 27–32. DOI: 10.5753/se4fp.2025.14570
  • SEDA (original): Welsh, Matt; Culler, David; Brewer, Eric. “SEDA: An Architecture for Well-Conditioned, Scalable Internet Services.” ACM SIGOPS Operating Systems Review, Vol. 35, No. 5, 2001.
  • Pedagogical context: Staroletov, Sergey. “Modern Erlang Teaching from Code Refactoring to Actor System Verification.” Erlang Workshop 2026. DOI: 10.1145/3830434.3830946
  • Code: github.com/fernandoareias/web-server-erl

Have you built a SEDA-style system in Erlang — or tried to port the architecture to another runtime? What was the hardest part to get right? Let me know in the comments.

Top comments (0)