惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

The Cloudflare Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
人人都是产品经理
人人都是产品经理
C
Check Point Blog
有赞技术团队
有赞技术团队
H
Help Net Security
V
Vulnerabilities – Threatpost
N
News | PayPal Newsroom
Hacker News - Newest:
Hacker News - Newest: "LLM"
L
LINUX DO - 最新话题
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - 【当耐特】
爱范儿
爱范儿
I
InfoQ
V
Visual Studio Blog
O
OpenAI News
Google DeepMind News
Google DeepMind News
S
Security Affairs
T
Troy Hunt's Blog
P
Palo Alto Networks Blog
Spread Privacy
Spread Privacy
Engineering at Meta
Engineering at Meta
雷峰网
雷峰网
N
Netflix TechBlog - Medium
Latest news
Latest news
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Webroot Blog
Webroot Blog
S
Schneier on Security
MongoDB | Blog
MongoDB | Blog
T
Tor Project blog
V2EX - 技术
V2EX - 技术
Security Latest
Security Latest
Cloudbric
Cloudbric
The GitHub Blog
The GitHub Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
B
Blog RSS Feed
C
CERT Recently Published Vulnerability Notes
T
The Exploit Database - CXSecurity.com
P
Privacy International News Feed
S
Securelist
C
Cisco Blogs
博客园_首页
TaoSecurity Blog
TaoSecurity Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Security Archives - TechRepublic
Security Archives - TechRepublic
P
Proofpoint News Feed
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
T
Threat Research - Cisco Blogs
阮一峰的网络日志
阮一峰的网络日志
S
Secure Thoughts

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Standalone HTTP Server in Elixir with Bandit
Mathieu Kerjouan · 2026-06-03 · via DEV Community

Writing Elixir code is not really exciting to me, but, to be honest, if someone today wants to create an application from scratch and is looking for a big pool developers and a battle tested distributed infrastructure (the BEAM VM), Elixir is probably one of the best choice nowadays. The community is active, the documentation is great, the language looks like a mix between Ruby and Python, without the annoying object part and with all the good feature from Erlang. This is why, today, we will use Elixir with bandit to create a minimal web server supporting both HTTP and WebSockets.

bandit has been created as an alternative to cowboy, fully coded in Elixir and designed to be integrated and highly compatible with the Phoenix framework. When it came out few years ago, the community did a lot of noise and I never find a moment to test it. bandit  supports HTTP/1.1 and HTTP/2 natively. WebSockets protocols support can be added with the help of WebSock and WebSockAdapter modules. At this time, it does not support HTTP/3 (yet?).

The idea here is not to follow blindly the Phoenix framework documentation and to create yet another website-like-project, but just to learn how to use bandit API, a bit like I did in a previous publication on cowboy. Let start our journey by creating a new Elixir library called desperados. It will contain all our experiments.

Bootstrapping

Elixir is using Mix as project manager. To create a new library-like project, one can simply call mix new command

$ mix new desperados

Enter fullscreen mode Exit fullscreen mode

A library template project should have been generated into the desperados directory. I will not create an application or an umbrella for now, I just want something minimalist to break. It also means the application will be started manually, usually with a start like function.

Starting Bandit

The first file to modify is lib/desperados.ex, it will contain the methods to start and stop the application. Let simply implement start_link/1 and start_link/0 for now.

defmodule Desperados do
  def start_link do
    start_link([
      {:plug, Desperados.Plugs},
      {:scheme, :http},
      {:port, 8082},
      {:ip, :any},
      {:startup_log, :debug}
    ])
  end

  def start_link(opts) do
    Bandit.start_link(opts)
  end
end

Enter fullscreen mode Exit fullscreen mode

bandit is using thousand_island to manage the transport layer (TCP). This project is also a rewrite of ranch in pure Erlang and designed for the Phoenix framework. The function to start a new bandit server is Bandit.start_link/1. The unique argument passed to it contains the options() to configure the server.

The smallest configuration is to only set a Plug handler. This Plug callback is mandatory. In fact, when using bandit with Phoenix, this callback can be seen as the Phoenix router. Anyway, to work correctly, a Plug handler is required, so let call it Desperados.Plug for now. The rest of the options are for configuring the server itself

Anatomy of a Plug

A Plug is a piece of code containing mandatory callback functions (behaviours) to manage initialization, connections, requests and events. These callbacks functions are following a contract (specification) and must return a certain kind of value. If you are more familiar with Erlang, one can think of a middleware in cowboy. The Plug concept is the foundation of any Phoenix application.

Why Plugs are great? Well, because they are following a contract, those modules can be connected together using a pipeline. The output of one Plug can be the input of another one. This kind of concept makes everything easier. You can see that a bit like a layer of features, where every Plug will deal with only with one request passing through one Plug at a time (e.g. authentication, sanitization...) and the "final" value returned by one of those Plug will be the response to the client.

Let create the most simple Plug possible, Desperados.Plugs.Inspect, a simple Plug to inspect the %Plug.Conn{} data structure passed in the first argument of the call/2 function callback. This file will be created under lib/desperados/plugs directory and called inspect.ex.

defmodule Desperados.Plugs.Inspect do
  @behaviour Plug

  def init(_args), do: {:ok, %{}}

  def call(conn, _state) do
    IO.inspect(conn)
  end
end

Enter fullscreen mode Exit fullscreen mode

A Plug is a concise module, when a request is coming to the server from a client, the first callback executed is init/1, it will simply initialize the the Plug using some kind of arguments passed previously. In this step, a state can be defined, in our case, an empty Map is returned. This state will be then be passed to other callback functions.

If the initialization phase was a success, then the server will call the call/2 function callback with the full request in the first argument using a %Plug.Conn{} data structure and the state previously returned by the init/1 function.

This new plug can be directly used in the Bandit.start_link/1 options but well, I told you previously we can pipeline Plugs together. This feature is already available by default in Phoenix when using the Router plugs feature. We are not using Phoenix, but, we can use Plug.Builder to create our own Plug router using Plug.Builder. Let create a new modules called Desperados.Plugs in lib/desperados/plugs.ex. It will contain the following code:

defmodule Desperados.Plugs do
  use Plug.Builder
  plug Desperados.Plugs.Inspect
end

Enter fullscreen mode Exit fullscreen mode

The Plug.Builder module offers the plug macro. After compilation, all the plugs defined are then put together in a pipeline, where the first defined will be executed at first and the last one, executed during the last step. Let start our minimal configuration right now using iex.

$ iex -S mix
Erlang/OTP 28 [erts-16.0.1] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit:ns]

Interactive Elixir (1.19.5) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Desperados.start_link

13:31:27.131 [debug] Running Desperados.Plugs with Bandit 1.11.1 at 0.0.0.0:8082 (http)
{:ok, #PID<0.214.0>}

Enter fullscreen mode Exit fullscreen mode

It seems good, the server is started. We can now use curl to check that.

$ curl -svfq http://localhost:8082/test; echo $?
* Host localhost:8082 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* HTTPS-RR: -
*   Trying [::1]:8082...
* connect to ::1 port 8082 from ::1 port 38724 failed: Connection refused
*   Trying 127.0.0.1:8082...
* Established connection to localhost (127.0.0.1 port 8082) from 127.0.0.1 port 42484 
* using HTTP/1.x
> GET /test HTTP/1.1
> Host: localhost:8082
> User-Agent: curl/8.20.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 500 Internal Server Error
< connection: close
* The requested URL returned error: 500
< 
* closing connection #0
22

Enter fullscreen mode Exit fullscreen mode

Ah! It returns an error an error 500! Let check the console on iex to see what happened...

%Plug.Conn{
  adapter: {Bandit.Adapter, :...},
  assigns: %{},
  body_params: %Plug.Conn.Unfetched{aspect: :body_params},
  cookies: %Plug.Conn.Unfetched{aspect: :cookies},
  halted: false,
  host: "localhost",
  method: "GET",
  owner: nil,
  params: %Plug.Conn.Unfetched{aspect: :params},
  path_info: ["test"],
  path_params: %{},
  port: 8082,
  private: %{},
  query_params: %Plug.Conn.Unfetched{aspect: :query_params},
  query_string: "",
  remote_ip: {127, 0, 0, 1},
  req_cookies: %Plug.Conn.Unfetched{aspect: :cookies},
  req_headers: [
    {"host", "localhost:8082"},
    {"user-agent", "curl/8.20.0"},
    {"accept", "*/*"}
  ],
  request_path: "/test",
  resp_body: nil,
  resp_cookies: %{},
  resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}],
  scheme: :http,
  script_name: [],
  secret_key_base: nil,
  state: :unset,
  status: nil
}

13:32:18.749 [error] ** (Plug.Conn.NotSentError) a response was neither set nor sent from the connection
    (bandit 1.11.1) lib/bandit/pipeline.ex:169: Bandit.Pipeline.commit_response!/1
    (bandit 1.11.1) lib/bandit/pipeline.ex:46: Bandit.Pipeline.run/5
    (bandit 1.11.1) lib/bandit/http1/handler.ex:13: Bandit.HTTP1.Handler.handle_data/3
    (bandit 1.11.1) lib/bandit/delegating_handler.ex:18: Bandit.DelegatingHandler.handle_data/3
    (bandit 1.11.1) lib/bandit/delegating_handler.ex:8: Bandit.DelegatingHandler.handle_continue/2
    (stdlib 7.0.1) gen_server.erl:2424: :gen_server.try_handle_continue/3
    (stdlib 7.0.1) gen_server.erl:2291: :gen_server.loop/4
    (stdlib 7.0.1) proc_lib.erl:333: :proc_lib.init_p_do_apply/3

iex(2)> 

Enter fullscreen mode Exit fullscreen mode

It "works", at least, the conn argument is printed to stdout but the application print also an error. In fact, this is normal, the request does not return a response to the client and then, bandit throw an Plug.Conn.NotSentError error. To avoid this kind of issue, a Plug must give a response to a client. Let fix that in the next section.

Quick note regarding a Plug, it's also possible to create one using a function, in this case, the first argument passed must be a %Plug.Conn{} and the second argument can be anything (options).

Anyway, if you want to know a bit more about a plug, check those links:

  • The official Plug documentation API where you will find great example and the description of the behavior;

  • The official phoenix Plug tutorial, wwhere you will find how plugs are working when using them in a Phoenix project;

  • Plug source code on Github, where you will understand how the plug's macros have been implemented;

  • Plug.Router source code on Github, where you will understand how the router has been implemented using mostly Elixir macros;

  • Plug.Builder source code on Github where you will learn how the Plug Builder has been created and how the pipeline is working under the hood.

Dealing with HTTP Requests

We can print the connections/requests received with Desperados.Plugs.Inspect Plug, but it's a bit useless. It returns an error to the client and except for debugging purpose, there are no real values. Let create a new module called Desperados.Endpoints.HTTP to deal with the HTTP requests. A new file in lib/desperados/endpoints/http.ex can be created.

defmodule Desperados.Endpoints.HTTP do
  @behaviour Plug
  import Plug.Conn

  def init(_opts), do: {:ok, %{}}

  def call(conn, _state) do
    send_resp(conn, 404, "not found")
  end
end

Enter fullscreen mode Exit fullscreen mode

The Plug structure is the very same than the previous one we just created. We have the init/1 function callback doing mostly nothing and then we have the call/2 function doing something interesting. When a request is received, this plug will call Plug.Conn.send_resp/3 function to reply a message to the client. In our case, this code is simply returning the code 404 with the message "not found". Let add this in our Desperados.Plugs "pipeline".

defmodule Desperados.Plugs do
  use Plug.Builder
  plug Desperados.Plugs.Inspect
  plug Desperados.Endpoints.HTTP
end

Enter fullscreen mode Exit fullscreen mode

The project can now be recompiled in live using recompile function inside the iex shell.

iex(2)> recompile
:ok

Enter fullscreen mode Exit fullscreen mode

Great. curl can now be used to request the server with a GET method. If it works, it should return a 404 not found.

$ curl -v http://localhost:8082/test
* Host localhost:8082 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* HTTPS-RR: -
*   Trying [::1]:8082...
* connect to ::1 port 8082 from ::1 port 34846 failed: Connection refused
*   Trying 127.0.0.1:8082...
* Established connection to localhost (127.0.0.1 port 8082) from 127.0.0.1 port 35288 
* using HTTP/1.x
> GET /test HTTP/1.1
> Host: localhost:8082
> User-Agent: curl/8.20.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 404 Not Found
< date: Sat, 30 May 2026 11:57:36 GMT
< content-length: 9
< vary: accept-encoding
< cache-control: max-age=0, private, must-revalidate
< 
* Connection #0 to host localhost:8082 left intact
not found

Enter fullscreen mode Exit fullscreen mode

No more errors, Desperados.Endpoints.HTTP is returning an "awesome" 404 not found.

%Plug.Conn{
  adapter: {Bandit.Adapter, :...},
  assigns: %{},
  body_params: %Plug.Conn.Unfetched{aspect: :body_params},
  cookies: %Plug.Conn.Unfetched{aspect: :cookies},
  halted: false,
  host: "localhost",
  method: "GET",
  owner: nil,
  params: %Plug.Conn.Unfetched{aspect: :params},
  path_info: ["test"],
  path_params: %{},
  port: 8082,
  private: %{},
  query_params: %Plug.Conn.Unfetched{aspect: :query_params},
  query_string: "",
  remote_ip: {127, 0, 0, 1},
  req_cookies: %Plug.Conn.Unfetched{aspect: :cookies},
  req_headers: [
    {"host", "localhost:8082"},
    {"user-agent", "curl/8.20.0"},
    {"accept", "*/*"}
  ],
  request_path: "/test",
  resp_body: nil,
  resp_cookies: %{},
  resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}],
  scheme: :http,
  script_name: [],
  secret_key_base: nil,
  state: :unset,
  status: nil
}
iex(3)>

Enter fullscreen mode Exit fullscreen mode

No crashes on the server side anymore as well. The conn variable is still correctly printed to STDOUT and everything is working correctly. Is it? Well, perhaps not, what about WebSocketd support?

$ curl -v ws://localhost:8082/test
* Host localhost:8082 was resolved.                                                                     * IPv6: ::1
* IPv4: 127.0.0.1
* HTTPS-RR: -
*   Trying [::1]:8082...
* connect to ::1 port 8082 from ::1 port 52100 failed: Connection refused
*   Trying 127.0.0.1:8082...
* Established connection to localhost (127.0.0.1 port 8082) from 127.0.0.1 port 43686 
* using HTTP/1.x
> GET /test HTTP/1.1
> Host: localhost:8082
> User-Agent: curl/8.20.0
> Accept: */*
> Upgrade: websocket
> Sec-WebSocket-Version: 13
> Sec-WebSocket-Key: mkqOjlvEfpDPU53U0uVnHw==
> Connection: Upgrade
> 
* Request completely sent off
< HTTP/1.1 404 Not Found
< date: Sat, 30 May 2026 11:59:43 GMT
< content-length: 9
< vary: accept-encoding
< cache-control: max-age=0, private, must-revalidate
* Refused WebSocket upgrade: 404
< 
* closing connection #0
curl: (22) Refused WebSocket upgrade: 404

Enter fullscreen mode Exit fullscreen mode

Ah! It does not work... Let fix that in the next section then. Anyway, if you want to know more about managing HTTP requests with Plugs, you should also check those links:

Dealing with WebSockets

bandit does not offer WebSockets natively and depends of 2 modules for doing that: WebSock and WebSockAdapter. The first one is used to deal with the events by "extending" the Plug behavior, the second one is used to initialize the WebSocket link with the client. The creation of a small Plug in charge of WebSockets connection still remains easy to implement. Let create a new endpoint called Desperados.Endpoints.Websocket.

defmodule Desperados.Endpoints.Websocket do
  @behaviour Plug

  def init(_opts) do
    state = %{count: 0}
    {:ok, state}
  end

  def call(conn, _state) do
    WebSockAdapter.upgrade(conn, __MODULE__, %{}, timeout: 60_000)
    |> Plug.Conn.halt()
  end
  def call(conn, _opts), do: IO.inspect(conn)

  def handle_in(_event = {"ping\n", [opcode: opcode]}, state) do
    {:reply, :ok, {opcode, "pong\n"},
      state 
      |> Map.update(:count, 0, fn value ->
        value+1
      end)
    }
  end
  def handle_in(_event = {data, opcode: opcode}, state) do
    {:reply, :ok, {opcode, data}, state}
  end
end

Enter fullscreen mode Exit fullscreen mode

This Plug assumes the client is starting to upgrade the HTTP connection to a WebSocket one. During the call/2 callback, the WebSockAdapter.upgrade/4 function is called to acknowledge the upgrade. The first argument is the connection, the second one is the module callback (in our case the name of the current module), the third argument is a state and fourth arguments are websocket connection options. If the handshake is valid, this function will give the control to this connection to a WebSock process.

In this situation, every time an events (e.g. messages) are received, they are transmitted to the handle_in/2 function callback. The Plug router can be modified,

defmodule Desperados.Plugs do
  use Plug.Builder
  plug Desperados.Plugs.Inspect
  plug Desperados.Endpoints.Websocket
end

Enter fullscreen mode Exit fullscreen mode

The Desperados.Endpoints.HTTP has been removed there, because the pipeline cannot support both HTTP and WebSockets at the same time (for now). Again, let recompile the project.

iex (3)> recompile
:ok

Enter fullscreen mode Exit fullscreen mode

curl can now be used again, this time as a WebSocket client.

$ curl -vT. --no-progress-meter ws://localhost:8082/test
* Host localhost:8082 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* HTTPS-RR: -
*   Trying [::1]:8082...
* connect to ::1 port 8082 from ::1 port 36962 failed: Connection refused
*   Trying 127.0.0.1:8082...
* Established connection to localhost (127.0.0.1 port 8082) from 127.0.0.1 port 56496 
* using HTTP/1.x
> GET /test HTTP/1.1
> Host: localhost:8082
> User-Agent: curl/8.20.0
> Accept: */*
> Upgrade: websocket
> Sec-WebSocket-Version: 13
> Sec-WebSocket-Key: GIPpTnAnww9DUOoZGJSiYg==
> Connection: Upgrade
> 
* Request completely sent off
< HTTP/1.1 101 Switching Protocols
< date: Sat, 30 May 2026 18:12:50 GMT
< upgrade: websocket
< connection: Upgrade
< sec-websocket-accept: aMzngyTPS2svr8fgKowmhG9vb44=
< cache-control: max-age=0, private, must-revalidate
< 
* Received 101, Switching to WebSocket
* [WS] Received 101, switch to WebSocket
test
} [11 bytes data]
test
test
test
* upload completely sent off: 22 bytes

Enter fullscreen mode Exit fullscreen mode

It works as expected, the WebSocket server is returning the messages we are sending. Now, I would like to also implement something neat, a plug to detect if the client wants to upgrade the connection and assign a :websocket tag to it if it's the case. In short: one path with HTTP and WebSocket support. Let create that in the next section.

If you want to know more about WebSockets in Elixir, here few links to read:

HTTP/WebSockets Multi Protocol Endpoint

Usually, one path only is set with WebSocket support. On many examples, one can see /ws endpoint is dedicated for this task but what if one is able to bypass this convention and let a path be compatible with both HTTP and WebSockets? Yeah, it's a bit dirty, but it's also a good way to learn how WebSockets is working.

The WebSockets protocol is starting with the client requesting a connection upgrade containing at least 3 mandatory HTTP headers:

  • Upgrade set with the string websocket;

  • Sec-WebSocket-Version set with the version (or the list of versions) of the WebSocket protocol to use;

  • Sec-WebSocket-key set with a random string (usually a base64 one) and mostly used to ensure this is a correct WebSocket connection requested by the client.

With this information, a Plug can be created to check if those headers are present in the request. If it's the case, we can be pretty sure this is a WebSocket request from a client, else, this is probably a simple HTTP request. Let create a new Plug called Desperados.Plugs.Websocket to help us identify those kind of connections.

defmodule Desperados.Plugs.Websocket do
  @behaviour Plug
  import Plug.Conn

  def init(_args), do: {:ok, %{}}

  def call(conn, state), do: header_upgrade(conn, state)

  defp header_upgrade(conn, state) do
    case get_req_header(conn, "upgrade") do
      [v = "websocket"] ->
        conn
        |> assign(:ws_info, [v])
        |> header_version(state)
      _ ->
        conn
    end
  end

  defp header_version(conn = %Plug.Conn{assigns: %{ws_info: ws}}, state) do
    case get_req_header(conn, "sec-websocket-version") do
      [v] ->
        conn
        |> assign(:ws_info, [v|ws])
        |> header_key(state)
      _ ->
        conn
    end
  end
  defp header_version(conn, _state), do: conn

  defp header_key(conn = %Plug.Conn{assigns: %{ws_info: ws}}, state) do
    case get_req_header(conn, "sec-websocket-key") do
      [k] ->
        conn
        |> assign(:ws_info, [k|ws])
        |> final(state)
      _ ->
        conn
    end
  end
  defp header_key(conn, _state), do: conn

  defp final(conn = %Plug.Conn{assigns: %{ws_info: [_,_,_]}}, _state) do
    assign(conn, :websocket, true)
  end
  defp final(conn, _state), do: conn
end

Enter fullscreen mode Exit fullscreen mode

This is not a really complex Plug, every functions are acting like a waterfall, the first one is looking for the Upgrade header, if it's present, then it will call the second function. This one will look for the Sec-Websocket-Version header, if it's present, then it will call the third function to check if the Sec-Websocket-Key can be found. If those 3 headers are present, the :websocket key set to true is assigned to the connection.

The Desperados.Endpoints.Websocket needs a small patch now, to only start the upgrading process when a connection has the :websocket key assigned.

defmodule Desperados.Endpoints.Websocket do
  @behaviour Plug

  def init(_opts) do
    state = %{count: 0}
    {:ok, state}
  end

  def call(conn = %Plug.Conn{assigns: %{websocket: true}}, _state) do
    WebSockAdapter.upgrade(conn, __MODULE__, %{}, timeout: 60_000)
    |> Plug.Conn.halt()
  end
  def call(conn, _opts), do: IO.inspect(conn)

  def handle_in(_event = {"ping\n", [opcode: opcode]}, state) do
    {:reply, :ok, {opcode, "pong\n"},
      state 
      |> Map.update(:count, 0, fn value ->
        value+1
      end)
    }
  end
  def handle_in(_event = {data, opcode: opcode}, state) do
    {:reply, :ok, {opcode, data}, state}
  end
end

Enter fullscreen mode Exit fullscreen mode

The magic in the call/2 callback function, with the pattern %Plug.Conn{assigns: %{websocket: true}}. If it matches, then the connection will be upgraded, else, it will simply returns the raw connection to the pipeline.

Plug Pipeline

This is mostly done, one of the last section of this post. We have previously created a Plug to print the connection data-structure to stdout, then, a Plug to deal with HTTP requests and another one to deal with WebSockets. Another one was created to alter the connection data-structure and identify if a client is trying to initiate a WebSocket connection. Let update our pipeline.

defmodule Desperados.Plugs do
  use Plug.Builder

  plug Desperados.Plugs.Websocket
  plug Desperados.Endpoints.Websocket
  plug Desperados.Endpoints.HTTP
end

Enter fullscreen mode Exit fullscreen mode

Lean, isn't it? The first Plug will check the client request to see if it's a WebSocket upgrade, if it's the case, the second Plug will deal with it, else, the HTTP Plug will do the job. Desperados.Plugs.Inspect as be removed, it's currently not necessary to have it, but it can be added whenever needed.

Final Test

This is the final part of this post, and it's now the time to test all the features. A test suite would have been the best thing to do, but this publication is already too long. Let just restart the application from scratch and play with curl.

$ iex -S mix
Erlang/OTP 28 [erts-16.0.1] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit:ns]

Interactive Elixir (1.19.5) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Desperados.start_link()

20:11:42.808 [debug] Running Desperados.Plugs with Bandit 1.11.1 at 0.0.0.0:8082 (http)
{:ok, #PID<0.245.0>}

Enter fullscreen mode Exit fullscreen mode

Let run the curl WebSocket test:

$ curl -vT. --no-progress-meter ws://localhost:8082/test
* Host localhost:8082 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* HTTPS-RR: -
*   Trying [::1]:8082...
* connect to ::1 port 8082 from ::1 port 43916 failed: Connection refused
*   Trying 127.0.0.1:8082...
* Established connection to localhost (127.0.0.1 port 8082) from 127.0.0.1 port 39416 
* using HTTP/1.x
> GET /test HTTP/1.1
> Host: localhost:8082
> User-Agent: curl/8.20.0
> Accept: */*
> Upgrade: websocket
> Sec-WebSocket-Version: 13
> Sec-WebSocket-Key: 7S1xZKpgqoGgC+gI3dtKyQ==
> Connection: Upgrade
> 
* Request completely sent off
< HTTP/1.1 101 Switching Protocols
< date: Sat, 30 May 2026 18:42:15 GMT
< upgrade: websocket
< connection: Upgrade
< sec-websocket-accept: jLWCKodpG0BsuWJT39MhgZTNLq8=
< cache-control: max-age=0, private, must-revalidate
< 
* Received 101, Switching to WebSocket
* [WS] Received 101, switch to WebSocket
test
} [11 bytes data]
test
test
test
* upload completely sent off: 22 bytes

Enter fullscreen mode Exit fullscreen mode

Let run the curl HTTP test:

$ curl -v http://localhost:8082/test
* Host localhost:8082 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* HTTPS-RR: -
*   Trying [::1]:8082...
* connect to ::1 port 8082 from ::1 port 40422 failed: Connection refused
*   Trying 127.0.0.1:8082...
* Established connection to localhost (127.0.0.1 port 8082) from 127.0.0.1 port 34666 
* using HTTP/1.x
> GET /test HTTP/1.1
> Host: localhost:8082
> User-Agent: curl/8.20.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 404 Not Found
< date: Sat, 30 May 2026 18:42:54 GMT
< content-length: 9
< vary: accept-encoding
< cache-control: max-age=0, private, must-revalidate
< 
* Connection #0 to host localhost:8082 left intact
not found

Enter fullscreen mode Exit fullscreen mode

This is working! More is needed to make it production ready, but a client can use both HTTP or WebSockets protocol with the same endpoint! Yeah, that's not really useful, but it was a small objective just to demonstrate how Plugs can be used.

Conclusion

The code required to create a really small HTTP/WebSocket server is incredibly lean. That's a good thing. Even more, if we are trusting the documentation, bandit is faster than cowboy. If we add the Elixir/Phoenix ecosystem, one can create a strong backend in a short amount of time with a huge ton of features.

Have fun!


Cover Image by Richie Bettencourt on Unsplash