9 min read
On this page

gen_tcp Basics

Elixir does not ship its own socket library. When you need raw TCP, you reach across to Erlang's :gen_tcp, exactly as you would in any other Erlang program. This is one of those moments where the "Elixir is Erlang with a nicer syntax" framing stops being marketing and starts being literal — you call :gen_tcp.listen/2 and :gen_tcp.accept/1 directly, no wrapper, no shim. The good news is that :gen_tcp is one of the most battle-tested networking layers in production software. Cowboy, Ranch, Phoenix, WhatsApp's chat backbone, Heroku's early routing layer, and Discord's voice gateway all sit on top of it.

The BEAM's networking is fast for reasons that have nothing to do with Elixir or Erlang the languages. Each socket is bound to a port driver written in C, async I/O is handled by the runtime, and incoming data is delivered as messages to a single owning process. You get one lightweight process per connection — millions of them per node is normal — without the overhead of a thread or the contortions of an event loop. The runtime handles the dispatch; you handle the protocol.

Opening a Client Connection

The client side is the simpler half. You connect, you send, you receive, you close.

{:ok, socket} = :gen_tcp.connect(~c"example.com", 80, [:binary, active: false])

:ok = :gen_tcp.send(socket, "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")

{:ok, response} = :gen_tcp.recv(socket, 0, 5_000)

:gen_tcp.close(socket)

A few things to notice. The hostname is a charlist (~c"example.com"), not a binary — :gen_tcp predates Elixir's preference for binaries and still wants charlists for hostnames. The options list contains :binary, which tells the socket to deliver received data as binaries rather than charlists. active: false puts the socket in passive mode, which means you call :gen_tcp.recv/3 explicitly to pull data; it will not arrive as a message on its own.

For a TLS connection you would swap :gen_tcp for :ssl with mostly the same shape — covered in the third part of this topic.

Listening and Accepting

The server side has two steps that are easy to confuse. You listen to create a passive listener socket, then you accept to take a single inbound connection off the kernel's accept queue.

{:ok, listen_socket} =
  :gen_tcp.listen(4040, [
    :binary,
    packet: :line,
    active: false,
    reuseaddr: true
  ])

{:ok, client_socket} = :gen_tcp.accept(listen_socket)

accept/1 blocks until a client connects, then returns a brand-new socket dedicated to that connection. The listen socket stays open and can accept the next one. The pattern in real servers is to spawn a fresh process for each connection, hand it the client socket, and immediately call accept/1 again — covered in detail in the next part of this topic.

reuseaddr: true is almost always what you want during development. It lets you restart the server without waiting for the kernel's TIME_WAIT window to expire on the port.

Active vs Passive Mode

This is the choice that defines how data flows into your process, and it trips up everyone the first time they meet it.

In passive mode (active: false), data does not arrive on its own. You call :gen_tcp.recv(socket, bytes_or_packet, timeout) and the runtime blocks the calling process until data is ready or the timeout fires. This is the easiest model to reason about — your code reads like a synchronous protocol — but it ties up a process per pending read.

def read_loop(socket) do
  case :gen_tcp.recv(socket, 0, 30_000) do
    {:ok, data} -> handle(data); read_loop(socket)
    {:error, :closed} -> :ok
    {:error, :timeout} -> read_loop(socket)
  end
end

In fully active mode (active: true), data arrives as messages to the socket's controlling process. You don't call recv at all; you write receive clauses for {:tcp, socket, data}, {:tcp_closed, socket}, and {:tcp_error, socket, reason}.

receive do
  {:tcp, ^socket, data} -> handle(data)
  {:tcp_closed, ^socket} -> :ok
end

This sounds nice — it integrates with GenServer.handle_info/2 cleanly — but it has a dangerous failure mode. A fast peer can flood your process with messages faster than you can consume them. Your mailbox grows without bound, garbage collection thrashes, and you OOM. Never use active: true against an untrusted peer.

The two safer variants:

active: :once delivers exactly one message, then drops the socket back into passive mode. After handling it, you call :inet.setopts(socket, active: :once) to re-arm. This is the canonical pattern for any real server — you get the GenServer-friendly message style and built-in back-pressure, because data only flows when you explicitly ask for the next chunk.

active: N where N is a positive integer delivers up to N messages, then the socket goes passive. Each message includes how many credits are left. You re-arm by calling setopts with a new N. This is what high-throughput protocols use when round-tripping :once per message would cost too many setopts calls. Ranch uses active: N internally to amortise the cost.

The right default for new code: active: :once. Move to active: N only when profiling shows the setopts overhead matters.

Packet Framing

TCP is a byte stream. It has no concept of messages. A send of 1000 bytes may arrive at the peer as a single recv of 1000 bytes, or two of 500, or 1000 of 1 — the network gets to choose. Every protocol on top of TCP needs a way to mark message boundaries, and re-implementing framing for every server is tedious. :gen_tcp bundles the common cases as the :packet option.

:gen_tcp.listen(4040, [:binary, packet: :line])

The values you will actually use:

  • packet: :line — each recv (or active message) gives you exactly one line, including the trailing \n. Useful for line-based protocols like SMTP, IRC, Redis-style ASCII commands.
  • packet: 2 — each message is prefixed by a 16-bit big-endian length. The runtime strips the prefix on read and adds it on send. Max payload 65535 bytes. Common for compact RPC protocols.
  • packet: 4 — same idea with a 32-bit prefix. Standard for Erlang's own distribution protocol and many custom binary protocols.
  • packet: :http_bin — parses HTTP request lines and headers for you, delivering them as structured tuples. This is what Cowboy uses for the request line; for the body it switches to :raw.
  • packet: :raw (the default) — no framing. You handle it yourself.

Pick framing at listen/2 time and the runtime does the buffering. The alternative — buffering raw bytes in your GenServer state, scanning for delimiters or length prefixes, handling partial messages across recv calls — is a few dozen lines of fiddly code that every Elixir programmer has written and gotten wrong at least once.

A Single-Connection Echo Server

To make all of that concrete, here is a server that accepts one connection at a time, echoes each line back, and loops back to accept again when the client disconnects.

defmodule EchoServer do
  def start(port) do
    {:ok, listen_socket} =
      :gen_tcp.listen(port, [
        :binary,
        packet: :line,
        active: false,
        reuseaddr: true
      ])

    accept_loop(listen_socket)
  end

  defp accept_loop(listen_socket) do
    {:ok, client} = :gen_tcp.accept(listen_socket)
    serve(client)
    accept_loop(listen_socket)
  end

  defp serve(socket) do
    case :gen_tcp.recv(socket, 0, :infinity) do
      {:ok, line} ->
        :gen_tcp.send(socket, line)
        serve(socket)

      {:error, :closed} ->
        :ok
    end
  end
end

Run it with EchoServer.start(4040), then test with nc localhost 4040. Type a line, get it echoed.

This server is deliberately bad. It handles exactly one client at a time. While one client is connected, every other connection sits in the kernel's accept queue. The next part of this topic turns this into the real pattern — one process per accepted connection, supervised under a DynamicSupervisor.

An Echo Client

For symmetry, here is the matching client:

defmodule EchoClient do
  def send_lines(host, port, lines) do
    {:ok, socket} =
      :gen_tcp.connect(to_charlist(host), port, [
        :binary,
        packet: :line,
        active: false
      ])

    for line <- lines do
      :gen_tcp.send(socket, line <> "\n")
      {:ok, reply} = :gen_tcp.recv(socket, 0, 5_000)
      IO.write(reply)
    end

    :gen_tcp.close(socket)
  end
end

EchoClient.send_lines("localhost", 4040, ["hello", "world"])

Note the to_charlist(host) — even when you have the hostname as a binary, you convert at the boundary. Forgetting this and passing a binary gives you an :einval error that takes longer to diagnose than it should.

Why the BEAM Is Fast at This

You will sometimes hear that "Erlang networking is slow because it goes through a port driver." The opposite is true, and it is worth understanding why.

The BEAM's socket implementation is built on a small set of C drivers — inet_drv on most systems — that use the platform's async I/O primitives: epoll on Linux, kqueue on BSD and macOS, IOCP on Windows. The drivers run on dedicated I/O threads, separate from the scheduler threads that run your Elixir code. When data arrives on a socket, the I/O thread delivers it as a message to the controlling process's mailbox; the scheduler picks the process up the next time it runs.

This separation is what makes one process per connection viable. You are not paying a kernel thread per connection like in a thread-per-request server, and you are not writing your own event loop with a state machine per connection like in nginx or Node.js. The runtime gives you the synchronous-looking code of the former with the efficiency of the latter.

WhatsApp famously ran on the order of two million concurrent TCP connections per node on a single physical machine. That is not a benchmark number — that is production traffic. The architecture is process-per-connection, all the way down, sitting on :gen_tcp and :ssl.

Common Pitfalls

Confusing the listen socket with a client socket. accept/1 returns a new socket for the accepted connection. The listen socket keeps listening. People sometimes try to recv from the listen socket or close it after the first accept; both are bugs.

Using active: true against an untrusted peer. Any peer that can send faster than you consume can blow up your process with a runaway mailbox. Default to active: :once, move to active: N only with measurements.

Forgetting to re-arm after active: :once. The socket goes passive after one message. If your handle_info does not call :inet.setopts(socket, active: :once), your server silently stops receiving. The connection looks alive, but no data flows.

Passing binaries where charlists are required. Hostnames in :gen_tcp.connect/3 want charlists. The error is :einval, which is unhelpfully generic. Wrap with to_charlist/1 at the boundary.

Skipping packet: and re-implementing framing. Every line-based protocol can use packet: :line. Every length-prefixed protocol can use packet: 2 or packet: 4. Doing it yourself is a source of off-by-one bugs and partial-read bugs that the runtime would have handled for you.

Calling :gen_tcp.close/1 without flushing. TCP close does not wait for the kernel to drain the send buffer in all cases. For protocols where the last message matters, either negotiate a graceful shutdown at the application layer or use :gen_tcp.shutdown(socket, :write) to signal you are done writing while still being able to read the peer's reply.

Leaving sockets in charlist mode by default. Omitting the :binary option means recv returns charlists, which are linked-list of integers. Every byte costs about 16 bytes of heap. For anything that handles real traffic this is a massive waste. Always include :binary in the options.

Key Takeaways

  • :gen_tcp is the Erlang module Elixir uses directly for TCP. There is no Elixir wrapper because none is needed.
  • connect/3 for clients, listen/2 plus accept/1 for servers. The listen socket and the client socket are distinct.
  • Active mode delivers data as messages; passive mode requires explicit recv calls. Default to active: :once for built-in back-pressure.
  • packet: options handle line framing, length-prefix framing, and HTTP parsing for you. Use them.
  • Per-connection processes plus async I/O drivers are why the BEAM scales to millions of connections per node.
  • A "real" server is the next step up from the echo server here — acceptor pattern with a DynamicSupervisor, covered next.