10 min read
On this page

gen_udp and ssl

TCP is the obvious default, but it is not the only protocol you reach for. UDP shows up whenever you cannot afford the latency of a three-way handshake or the overhead of per-connection state: DNS resolvers, syslog daemons, game netcode, telemetry agents, and the research stack underneath QUIC. TLS is the obvious wrapper whenever those bytes need to be encrypted, which on the modern internet is essentially always. Both :gen_udp and :ssl follow the same active/passive shape as :gen_tcp, which is what makes them easy to slot into the patterns from the previous two topics.

gen_udp Basics

UDP has no connection. There is no listen or accept. You open a socket, you send datagrams to whoever, and you receive datagrams from whoever happens to send one. Each datagram is delivered as a single unit — either intact, or not at all. The kernel never splits or merges them. That property is what makes UDP useful for things where partial messages would be worse than missed ones.

{:ok, socket} = :gen_udp.open(5353, [:binary, active: false])

:ok = :gen_udp.send(socket, ~c"8.8.8.8", 53, dns_query)

{:ok, {address, port, response}} = :gen_udp.recv(socket, 0, 5_000)

:gen_udp.close(socket)

The shape is similar to :gen_tcp but with one structural difference: recv returns the sender's address and port along with the data, because there is no implicit "connection" telling you who you are talking to. Every datagram is on its own.

Active mode delivers messages as {:udp, socket, address, port, data} and supports the same :once, N, and true variants. The same warnings apply — active: true against a fire-hose source (a syslog firehose, a metrics agent gone berserk) will fill your mailbox just as fast as TCP will, and a UDP source has no back-pressure mechanism at all. The peer will keep firing whether you read or not. For UDP, active: :once is non-negotiable for any untrusted source.

What UDP Costs You

It costs you everything TCP gives you for free.

  • No ordering. Datagrams can arrive out of order. If you send 1, 2, 3, your peer might see 1, 3, 2, or just 1, 3.
  • No reliability. Datagrams can be dropped silently. There is no retransmit, no acknowledgement.
  • No flow control. A fast sender will fill the kernel buffer on a slow receiver, and the kernel will drop the overflow.
  • No congestion control. UDP does not back off when the network is saturated. If you want that, you implement it on top.
  • A fixed MTU ceiling per datagram. Practically, around 1472 bytes for a payload on most networks before fragmentation kicks in. Anything bigger needs your own chunking.

What you get in exchange is no setup cost — no handshake before you can send — and no per-peer kernel state on a server that handles a thousand sporadic clients. For DNS, which sends a 50-byte question and gets a 100-byte answer and then forgets the peer existed, this is the right trade. For syslog, where dropping one log line out of a million is preferable to blocking the application that produced it, this is the right trade. For chat messages, where you absolutely need ordering and delivery, it is not.

QUIC, which is what HTTP/3 runs on, is essentially "reliable, encrypted, multiplexed streams reimplemented on top of UDP" because the UDP layer is the only layer you can change without kernel cooperation. The active research and production work on QUIC in the BEAM ecosystem all sits on :gen_udp.

A UDP Syslog Receiver

To show the shape, a tiny RFC 3164 syslog receiver:

defmodule Syslog.Receiver do
  use GenServer
  require Logger

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(opts) do
    port = Keyword.get(opts, :port, 514)
    {:ok, socket} = :gen_udp.open(port, [:binary, active: :once])
    {:ok, %{socket: socket}}
  end

  @impl true
  def handle_info({:udp, socket, ip, _port, packet}, %{socket: socket} = state) do
    Logger.info("syslog from #{:inet.ntoa(ip)}: #{packet}")
    :ok = :inet.setopts(socket, active: :once)
    {:noreply, state}
  end
end

There is no accept loop, no per-connection process, no dynamic supervisor. The whole server is one GenServer holding one socket, processing one datagram at a time. For low-rate sources, this is fine; for high-rate sources, you would shard by source IP across multiple receivers, each on its own socket bound with reuseport: true so the kernel hashes inbound traffic across them.

:inet.ntoa/1 turns the {a, b, c, d} IPv4 tuple back into a charlist like ~c"192.168.1.10". That is the kind of paper cut you remember once and never again.

ssl: TLS for Sockets

The :ssl module is the Erlang TLS implementation. It is shape-compatible with :gen_tcp:ssl.connect/3, :ssl.listen/2, :ssl.accept/1, :ssl.send/2, :ssl.recv/3, with active: options that behave the same way. The differences are all in the options: you supply certificates, you specify cipher suites and TLS versions, and you decide how strict the peer verification is.

A TLS client to a well-known service:

opts = [
  :binary,
  active: false,
  verify: :verify_peer,
  cacerts: :public_key.cacerts_get(),
  server_name_indication: ~c"example.com",
  customize_hostname_check: [
    match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
  ]
]

{:ok, socket} = :ssl.connect(~c"example.com", 443, opts, 10_000)

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

{:ok, response} = :ssl.recv(socket, 0, 10_000)

:ssl.close(socket)

A few things to flag.

verify: :verify_peer plus cacerts: :public_key.cacerts_get() is the modern way to validate server certificates. Earlier Erlang versions made you ship your own CA bundle or pull one from certifi; OTP 25 added cacerts_get/0 that returns the system trust store, which is what you want. The default of verify: :verify_none is not what you want — it accepts any certificate, including self-signed and expired ones. Always set verify: :verify_peer in production clients.

server_name_indication is what TLS calls "SNI" — the hostname you are claiming to want, sent in the ClientHello so the server can pick the right certificate. Without it, you cannot connect to most multi-tenant TLS endpoints.

The customize_hostname_check clause is unfortunately required for HTTPS-style hostname matching to work correctly. The default match logic does not handle wildcard certificates the way browsers do; the pkix_verify_hostname_match_fun(:https) helper fixes it. You can copy this incantation and forget about it.

For a TLS server, the shape mirrors a TCP server but with key and certificate paths in the listen options. The acceptor pattern from the previous topic works without modification — just replace every :gen_tcp call with the matching :ssl call and add the certificate options to :ssl.listen/2.

{:ok, listen_socket} =
  :ssl.listen(8443, [
    :binary,
    packet: :line,
    active: false,
    reuseaddr: true,
    certfile: "/etc/letsencrypt/live/example.com/fullchain.pem",
    keyfile: "/etc/letsencrypt/live/example.com/privkey.pem",
    versions: [:"tlsv1.3", :"tlsv1.2"]
  ])

Limiting versions: to 1.2 and 1.3 is the modern default. Anything older has known issues and should not be enabled.

Higher-Level Libraries (And When to Skip gen_tcp Entirely)

You very rarely call :gen_tcp or :ssl directly in application code. The Elixir ecosystem has a layered set of libraries that handle the common cases, and you should reach for them first.

Ranch. The socket acceptor pool used by Cowboy and (transitively) Phoenix. If you are building a custom protocol — a binary RPC, a game protocol, a message bus — Ranch is the right starting point. You implement the :ranch_protocol behaviour and Ranch handles the acceptor pool, supervision, listener configuration, and TLS termination. Maybe 1500 lines of well-tested Erlang.

Cowboy. HTTP/1.1, HTTP/2, and WebSocket server on top of Ranch. The default Phoenix adapter until recently. Battle-tested, conservative, integrates cleanly with the Plug ecosystem.

Bandit. A newer pure-Elixir HTTP/1.x and HTTP/2 server that does not use Ranch — it has its own acceptor pool. Faster in many benchmarks, more modern API, and is now the recommended Phoenix adapter for new projects. Worth reading the source for a clean Elixir take on the patterns from the previous topic.

Mint. A low-level HTTP client written in pure Elixir. It is intentionally not a connection pool or a high-level API; it is a single-connection state machine you drive yourself. If you are building infrastructure (a proxy, a metrics shipper, a load balancer), Mint is your starting point.

Finch. A connection-pooled HTTP client built on top of Mint and NimblePool. This is what most application code should use for HTTP calls. It supersedes HTTPoison and Tesla for most use cases — faster, fewer dependencies, better connection reuse.

Phoenix Channels and Phoenix PubSub. If your custom protocol is "fan out messages from one place to many subscribers," Phoenix Channels already solves that, ride on top of WebSockets or LongPolling, and integrate with PubSub for cross-node distribution. Discord built voice on a custom protocol, but their text chat and presence run on Phoenix Channels.

A reasonable rule for when to call :gen_tcp directly: when no existing library speaks your protocol, and you cannot reduce the protocol to "lines" (Cowboy/Plug) or "frames" (Phoenix Channels). That means:

  • A custom binary protocol — a financial market feed, an IoT line protocol like MQTT (though :emqx exists), a game state synchronisation protocol.
  • A protocol gateway that bridges legacy TCP traffic (SCADA, mainframe, older industrial gear) into modern systems.
  • A reference implementation or test harness for an emerging protocol where you cannot wait for someone to publish a library.

For everything else — HTTP, WebSockets, gRPC, GraphQL, REST — there is a library that has already solved it correctly, and your time is better spent on the business problem.

When You Do Build Directly on gen_tcp

The shape is unchanged across every project that does this:

  1. A listener with :gen_tcp.listen/2 and the right packet framing.
  2. An acceptor pool (or one acceptor, if you do not need the parallelism).
  3. A DynamicSupervisor for handlers.
  4. A handler GenServer per connection, in active: :once or active: N mode.
  5. A Registry or PubSub for cross-handler addressing if the protocol requires it.

Add Ranch and (2), (3), and most of the supervision tree become Ranch's responsibility. You write only the handler.

A common combination for a custom protocol with TLS:

:ranch.start_listener(
  :my_proto,
  :ranch_ssl,
  %{
    socket_opts: [
      port: 9443,
      certfile: cert,
      keyfile: key,
      versions: [:"tlsv1.3"]
    ],
    num_acceptors: 100
  },
  MyProto.Connection,
  []
)

MyProto.Connection implements start_link/3 and init/3, sets the socket to active: :once, and runs the protocol. About 60 lines of Elixir total, with Ranch handling everything else. This is what you should write when you decide a custom protocol is worth the engineering cost.

Common Pitfalls

Defaulting :ssl to verify: :verify_none. This is the equivalent of curl with -k. It accepts any certificate, including self-signed and expired ones. It defeats the entire point of TLS. Always verify: :verify_peer with a real trust store.

Forgetting hostname verification customisation in :ssl. The defaults do not match wildcard certs correctly for HTTPS. Add customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)] for HTTPS-style clients.

Assuming UDP delivery. Application code that sends one UDP datagram and waits for a reply with no timeout, no retransmit, and no idempotency will silently break on a lossy network. UDP needs retry and idempotency logic at the application layer.

Sending UDP datagrams larger than MTU. Fragmentation is allowed by IP but ill-advised. Many networks drop fragmented packets entirely. Stay under 1472 bytes for a safe payload size or implement chunking with sequence numbers.

Reaching for :gen_tcp when an HTTP client would do. "I need to talk to this internal service" is almost never the right reason to open a raw TCP socket. Finch or Mint plus a documented HTTP API gets you observability, retries, connection pooling, and load balancing for free.

Skipping Ranch when building a custom protocol. Writing your own acceptor pool, your own supervision strategy, and your own ownership-transfer handshake is reinventing what Ranch has already solved. Use Ranch unless you have a specific reason not to.

Using active: true with UDP from an untrusted source. UDP has no connection-level back-pressure, and active: true removes the BEAM-level one too. A malicious or buggy peer will OOM your process. active: :once is mandatory for public UDP endpoints.

Key Takeaways

  • :gen_udp is the UDP analogue of :gen_tcp. No connections, no accept loop — open, send, recv, and every datagram carries the sender's address.
  • UDP gives you no ordering, no reliability, no flow control, and a hard per-datagram size ceiling. The trade is no setup cost and minimal per-peer state.
  • :ssl is the Erlang TLS implementation. Shape-compatible with :gen_tcp — same active/passive semantics, same accept pattern. Always verify: :verify_peer with the system trust store in clients.
  • Higher-level libraries cover the common cases: Ranch for socket pools, Cowboy and Bandit for HTTP servers, Mint and Finch for HTTP clients, Phoenix Channels for fan-out.
  • Call :gen_tcp directly only when no existing library speaks your protocol and the protocol cannot be reduced to HTTP or WebSockets. Custom binary protocols, IoT line protocols, and protocol gateways are the legitimate cases.
  • When you do build directly, the shape is always the same: listener, acceptor pool, dynamic supervisor, handler per connection. Ranch is the standard implementation of the first three.