= WebSockets

Welcome

WebSockets provide a persistent, bidirectional connection between a client and a server. Once established, either party may send messages to the other at any time — making WebSockets the right choice for real-time features such as chat, live notifications, and collaborative editing.

If you only need a unidirectional stream of events from the server, consider Server-Sent Events instead.

What You Will Learn

After reading this guide, you will be able to:

  • Define a WebSocket route in Pedestal.

  • Handle WebSocket lifecycle events: open, message, and close.

  • Send text messages back to a connected client.

  • Connect to the WebSocket endpoint from a JavaScript client.

Guide Assumptions

This guide is for intermediate users who have worked through some sample applications or the first few Hello World guides. In particular, you should know how to run a build, start a REPL, and start and stop your server.

Getting Help if You’re Stuck

We’ll take this in small steps. If you get stuck at any point in this guide, please submit an issue about this guide or, hop over to the Pedestal Users mailing list and raise your hand there. You can also get help from the #pedestal channel on the Clojurians Slack.

WebSocket Overview

A WebSocket connection starts like any other HTTP request: the client sends a GET request with a special Upgrade: websocket header. If the server accepts, both sides switch to the WebSocket protocol on the same TCP connection. From that point on, either party can send text or binary messages at any time until one of them closes the connection.

Unlike SSE, where messages flow only from server to client, WebSockets are fully bidirectional.

Where We Are Going

We’ll create a simple echo endpoint at /ws/echo. When a client sends a text message, the server echoes it back prefixed with "echo: ".

Setting Up

Create a new project directory:

$ mkdir websocket-demo
$ cd websocket-demo
$ mkdir -p src
deps.edn
{:paths ["src"]
 :deps  {io.pedestal/pedestal.http-kit {:mvn/version "0.8.2-beta-8"}
         org.slf4j/slf4j-simple        {:mvn/version "2.0.17"}}}

Starting Simple

Let’s start with the namespace and a basic home page to confirm the server is running before we add WebSocket support.

src/websocket_demo.clj
(ns websocket-demo
  (:require [io.pedestal.connector :as conn]
            [io.pedestal.http.http-kit :as hk]
            [io.pedestal.service.websocket :as ws]))
src/websocket_demo.clj
(defn home-page
  [_request]
  {:status 200 :body "Hello, World!"})

Start the REPL, load the namespace, and verify the home page works:

$ clj
Clojure 1.12
user=> (require 'websocket-demo)
nil
user=> (websocket-demo/start)
...
$ curl http://localhost:8890/
Hello, World!

Adding a WebSocket Route

WebSocket connections are handled by a websocket interceptor, created with websocket-interceptor. You provide callback functions for the WebSocket lifecycle events you care about.

src/websocket_demo.clj
(def echo-interceptor
  (ws/websocket-interceptor
    ::echo
    {:on-open  (fn [_channel _request]                        (1)
                 (println "WebSocket connection opened")
                 nil)
     :on-text  (fn [channel _proc text]                       (2)
                 (ws/send-text! channel (str "echo: " text)))
     :on-close (fn [_channel _proc reason]                    (3)
                 (println "WebSocket connection closed:" reason))}))
1 :on-open is called when the connection is first established. Its return value becomes the process object — passed as the second argument to all subsequent callbacks. For a simple echo endpoint there’s no shared state needed, so we return nil.
2 :on-text receives each text message from the client. send-text! sends a reply back to the same client.
3 :on-close is called when the connection closes, regardless of which side initiated it. The reason is a keyword such as :normal or :abnormal.

There is also an :on-binary callback for binary (byte) messages.

Now add the route. WebSocket upgrade requests are ordinary HTTP GET requests, so the route uses :get as the method:

src/websocket_demo.clj
(def routes
  #{["/"         :get home-page    :route-name ::home]
    ["/ws/echo"  :get echo-interceptor]})                     (1)
1 WebSocket upgrade requests are always GET requests.

And the connector plumbing (unchanged from the basic setup):

src/websocket_demo.clj
(defn create-connector []
  (-> (conn/default-connector-map 8890)
      (conn/with-default-interceptors)
      (conn/with-routes routes)
      (hk/create-connector nil)))

(defonce *connector (atom nil))

(defn start []
  (reset! *connector (conn/start! (create-connector))))

(defn stop []
  (conn/stop! @*connector)
  (reset! *connector nil))

(defn restart []
  (when @*connector (stop))
  (start))

Trying It Out

Restart the connector to pick up the changes:

user=> (require :reload 'websocket-demo)
nil
user=> (websocket-demo/restart)
...

Install websocat if you don’t have it:

$ brew install websocat        # macOS
$ cargo install websocat       # or via Rust

Then connect and send a message:

$ websocat ws://localhost:8890/ws/echo
hello
echo: hello
goodbye
echo: goodbye

Type a line and press Enter; the server echoes it back prefixed with "echo: ". Press Ctrl-C to close the connection.

Connecting from JavaScript

To connect from a browser, use the standard WebSocket API:

const socket = new WebSocket("ws://localhost:8890/ws/echo");

socket.addEventListener("open", () => {
    socket.send("hello from the browser");
});

socket.addEventListener("message", (event) => {
    console.log("Received:", event.data);
});

socket.addEventListener("close", () => {
    console.log("Connection closed");
});

Coverage of the JavaScript WebSocket API is beyond the scope of this guide. Consult the MDN documentation for a thorough introduction.

The Whole Shebang

For reference, here is the complete source:

src/websocket_demo.clj
(ns websocket-demo
  (:require [io.pedestal.connector :as conn]
            [io.pedestal.http.http-kit :as hk]
            [io.pedestal.service.websocket :as ws]))

(defn home-page
  [_request]
  {:status 200 :body "Hello, World!"})

(def echo-interceptor
  (ws/websocket-interceptor
    ::echo
    {:on-open  (fn [_channel _request]                        (1)
                 (println "WebSocket connection opened")
                 nil)
     :on-text  (fn [channel _proc text]                       (2)
                 (ws/send-text! channel (str "echo: " text)))
     :on-close (fn [_channel _proc reason]                    (3)
                 (println "WebSocket connection closed:" reason))}))

(def routes
  #{["/"         :get home-page    :route-name ::home]
    ["/ws/echo"  :get echo-interceptor]})                     (1)

(defn create-connector []
  (-> (conn/default-connector-map 8890)
      (conn/with-default-interceptors)
      (conn/with-routes routes)
      (hk/create-connector nil)))

(defonce *connector (atom nil))

(defn start []
  (reset! *connector (conn/start! (create-connector))))

(defn stop []
  (conn/stop! @*connector)
  (reset! *connector nil))

(defn restart []
  (when @*connector (stop))
  (start))
deps.edn
{:paths ["src"]
 :deps  {io.pedestal/pedestal.http-kit {:mvn/version "0.8.2-beta-8"}
         org.slf4j/slf4j-simple        {:mvn/version "2.0.17"}}}

Wrapping Up

We’ve set up a minimal WebSocket endpoint, handled the open/message/close lifecycle, and tested it with websocat and from JavaScript.

For more details — connection limits, binary messages, subprotocols, and the full API — see the WebSockets Reference.