Courses/Real-Time Chat with WebSockets
Standalone Course~40 min•12 challenges

Build a Real-Time Chat App with WebSockets

HTTP is request-response: the client asks, the server answers. But what about live chat, notifications, or collaborative editing? You need the server to push data to the client the instant something happens. That's what WebSockets do. In this course you build a complete chat application on the hub your Grit project already has: rooms as authorized channels, a live member list, typing indicators that never touch the database, and message history that does. You install nothing.

SenderWebSocket HubEveryone elsepublishpushClient APOST /api/messagesHubPublish on a channelClient BsubscribedClient Csubscribed
Server pushNo polling
A message is written by REST, then published on its channel and pushed to everyone subscribed, on every replica

What are WebSockets?

When you load a web page, your browser sends an HTTP request and the server sends a response. The connection closes. If you want new data, you have to ask again. This works fine for loading pages, but it's terrible for real-time features. Imagine a chat app where you have to refresh the page to see new messages.

WebSocket: A communication protocol that provides a persistent, two-way connection between a client and server. Unlike HTTP (which opens and closes a connection for each request), a WebSocket connection stays open. Both the client and server can send data at any time without waiting for a request. WebSocket URLs start with ws:// (or wss:// for encrypted).
Full-Duplex Communication: A connection where both sides can send and receive data simultaneously. A phone call is full-duplex: both people can talk at the same time. HTTP is half-duplex, the client sends a request, then waits for the response. WebSockets are full-duplex: the server can push data to the client at the same time the client is sending data to the server.

The difference between HTTP and WebSocket:

HTTP vs WebSocket
HTTP (Request-Response):
  Client: "Any new messages?"     → Server: "No."
  Client: "Any new messages?"     → Server: "No."
  Client: "Any new messages?"     → Server: "Yes! Here's one."
  Client: "Any new messages?"     → Server: "No."
  (Wasteful: constant polling)

WebSocket (Persistent Connection):
  Client: "Open connection"       → Server: "Connected."
  ...
  Server: "New message from John!"  (pushed instantly)
  Server: "Jane is typing..."      (pushed instantly)
  Server: "New message from Jane!" (pushed instantly)
  (Efficient: data pushed only when it exists)

Real-world WebSocket use cases:

  • • Chat applications: Slack, Discord, WhatsApp Web
  • • Live notifications: GitHub, Twitter, Facebook
  • • Collaborative editing: Google Docs, Figma, Notion
  • • Real-time dashboards: stock prices, analytics, monitoring
  • • Multiplayer games: real-time game state synchronization
1

Challenge: Name 3 Real-Time Apps

Name 3 applications you use daily that rely on WebSockets (or similar real-time technology) for live features. For each one, explain what data is being pushed from the server to the client in real time.

The hub you already have

You do not install anything for this course. Every Grit project is scaffolded with a WebSocket hub in apps/api/internal/realtime, one endpoint atGET /api/ws, and a client in each frontend at lib/realtime.tswith hooks in hooks/use-realtime.ts. It is wired intoroutes.Setup before you write a line.

What the hub gives you:

  • • One socket per app: the client opens it once and every component shares it
  • • Authentication: the same JWT as the REST API, from the HttpOnly cookie in a browser
  • • Channels: named groups with an authorizer you write, which is what a chat room is
  • • Presence: a live member list per channel, kept in Redis so it survives a replica dying
  • • Client events: browser to browser, which is how a typing indicator costs nothing
  • • Reconnection: exponential backoff with jitter, and every subscription restored afterwards
  • • More than one replica: a Redis backplane, on by default wherever the project has Redis
An older course pointed at a separate grit-websockets plugin with its own/ws/room/:name routes. Do not use it: it is a second hub, with a second connection and no share of your app's authentication. Everything below runs on the hub your project already has.
2

Challenge: Find the Hub

Open apps/api/internal/realtime/ in your project. Readhub.go, channels.go and presence.go. Then find the line in internal/routes/routes.go that builds the hub, and the one that mounts/api/ws. Which config flag turns the whole thing off?

Channels are rooms

A chat room is a set of people who should see the same messages, which is exactly what a channel is. The client asks to join one by name, the server decides whether it may, and anything published on that channel reaches every connection that joined it, on every replica.

Channel: A named group of connections. A client sends subscribe with a channel name and the server answers subscribed or subscription_error. A connection can hold up to 100 channels at once, and the channel disappears when the last subscriber leaves.
Channel prefix: The first word of the name decides the rules. public- lets any connected client in with no check. private- and presence- call the authorizer you registered for that pattern; presence- additionally keeps a member list. A name with no registered pattern is refused.
Naming a room
presence-rooms.general     a room, with a member list
presence-rooms.support     another room
private-users.u_42         one user's private feed, no member list
public-status              anything signed in may watch

// the pattern you register is the name without the prefix:
//   rooms.{id}  covers presence-rooms.general and private-rooms.general
Use presence- for chat rooms. It is a private channel that also answers "who is in here", which you would otherwise build by hand out of join and leave messages and get wrong the first time a browser is closed without warning.
3

Challenge: Name Your Rooms

Write down the channel names for a chat app with three rooms: general, random and help. Then write the name for the private channel that would carry one user's direct messages. Which prefix does each need, and why?

Who is allowed in

An authorizer is a function that answers one question: may this user subscribe to this channel? It runs on every subscribe, including the ones after a reconnect, so a user removed from a room loses it the next time their connection drops. Register the patterns once, where the hub is built.

internal/routes/routes.gogo
// after realtimeHub := realtime.NewHub(realtimeOptions...)

realtime.Channel("rooms.{id}", func(c realtime.ChannelContext) bool {
    room := c.Param("id")
    user, err := services.UserByID(db, c.UserID)
    if err != nil || !services.IsRoomMember(db, user.ID, room) {
        return false
    }
    // Travels with the member in the presence list. Only put in here what
    // every other member of the room is allowed to see.
    c.SetInfo(map[string]any{"name": user.Name, "avatar": user.AvatarURL})
    return true
})

Three things worth knowing about the authorizer:

  • • It runs on the connection's own goroutine, so keep it to a query or two. A slow authorizer is a slow subscribe for that one client.
  • • A panic inside it refuses the subscription instead of taking the API down.
  • • c.SetInfo is the only way a member's name and avatar reach the other clients. Set it here, from the database, not from anything the client sent.
4

Challenge: Write an Authorizer

Register rooms.{id} in your project'sroutes.Setup. Start with a version that returns true for every signed-in user and sets the member's name with SetInfo. Restart the API. Then change it to return false and watch the browser get asubscription_error.

Subscribing from React

You do not write new WebSocket(...). The scaffolded client owns one connection for the whole app, reconnects on its own and resubscribes afterwards.useChannel joins a channel for as long as the component is mounted and hands each event to the handler named after it.

apps/web/components/ChatRoom.tsxtypescript
import { useChannel, useRealtimeStatus } from '@/hooks/use-realtime'
import { useQueryClient } from '@tanstack/react-query'

export function ChatRoom({ room }: { room: string }) {
  const queryClient = useQueryClient()
  const status = useRealtimeStatus()           // 'connecting' | 'open' | 'closed'
  const channel = 'presence-rooms.' + room

  useChannel(channel, {
    'messages.created': (message) => {
      queryClient.setQueryData(['messages', room], (old: Message[] = []) =>
        old.some((m) => m.id === message.id) ? old : [...old, message],
      )
    },
    subscription_error: (p) => console.warn('cannot join ' + room, p.message),
  })

  // ... render the message list and the input
}

Two details that save an afternoon each. Handlers are read through a ref, so an inline object literal does not tear the subscription down and rebuild it on every render. And passing null as the channel is allowed: use it while the room name is still loading, rather than mounting the component later.

The dedupe by id in the handler is not paranoia. The sender also gets the message back over the channel, and may already have added it optimistically. Making the handler idempotent is cheaper than trying to remember who sent what.
5

Challenge: Join a Channel

Add useChannel to a page in your web app, pointed atpresence-rooms.general, with a "*" handler thatconsole.logs everything. Open the page, then open the browser network tab and find the /api/ws connection. What is the first message the server sends?

Sending a message: in by REST, out by channel

A chat message is a row in the database. It is created the way every other row is created, by a POST to the REST API, where validation, authorization, rate limiting and the audit log already live. The socket is how everyone else hears about it, not how it is written.

SenderAPIEveryone in the roomwritethenpushPOST /api/messagesvalidated, authorizedInsert rowthe record of truthhub.Publishpresence-rooms.generalSubscribersevery replica
DurableBest effort
The database is the record. The channel is a hint that it changed, and a client that misses one resyncs on its next REST call
internal/handlers/message.gogo
func (h *MessageHandler) Create(c *gin.Context) {
    var req CreateMessageRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        respond.ValidationError(c, err)
        return
    }
    message, err := h.Service.Create(c.Request.Context(), actor(c), req)
    if err != nil {
        respond.Error(c, err)
        return
    }

    // Everyone in the room, on every replica. Never the whole hub.
    h.Hub.Publish("presence-rooms."+message.Room, realtime.Event{
        Type:    "messages.created",
        Payload: message,
    })
    respond.Created(c, message)
}
History is a REST call too: GET /api/messages?room=general&page=1 when the room opens, then the channel from that moment forward. A WebSocket is a bad way to page through the past, and the hub deliberately keeps nothing.
6

Challenge: Publish on Create

Generate a Message resource with grit generate resource Message room:string content:text. Add the hub.Publish call to its create handler. Open two browser tabs on the same room, post from one, and watch it appear in the other without a refresh.

Building the chat UI

A chat UI has four parts: a scrollable message list, an input, a connection indicator and, once you add presence, a member list. React Query holds the messages, the channel updates the same cache, and nothing polls.

Chat component structure
ChatRoom({ room })

  useQuery(['messages', room])        history, one REST call on open
  useChannel('presence-rooms.'+room)  new messages into the same cache
  usePresence('presence-rooms.'+room) who is here
  useRealtimeStatus()                 the dot in the header

  <header>   room name, member avatars, status dot
  <ul>       messages, ref on the container, scroll to bottom on change
  <form>     input, POST /api/messages, clear on success
  • • Auto-scroll: keep a ref on the list and scroll to scrollHeight in an effect on the message array, not in the socket handler
  • • Optimistic send: add the message to the cache before the POST returns, and dedupe by id when the channel echoes it back
  • • Status: closed means the user is looking at stale data. Say so, do not hide it
  • • Escape everything: a message is user input, and so is every whisper below
7

Challenge: Build the Room

Build the room component: history from React Query, live messages fromuseChannel, a status dot from useRealtimeStatus, and auto-scroll. Then kill your API with the page open. What does the dot do, and how long until it comes back when you start the API again?

Presence: who is in the room

Subscribing to a presence- channel gets you apresence.members snapshot, and everyone already there getspresence.joined. Closing the last socket a user holds on the channel sendspresence.left. One hook does all of it.

apps/web/components/RoomMembers.tsxtypescript
import { usePresence } from '@/hooks/use-realtime'

type Member = { name: string; avatar?: string }

export function RoomMembers({ room }: { room: string }) {
  const members = usePresence<Member>('presence-rooms.' + room)
  return (
    <div>
      <p>{members.length} here</p>
      <ul>
        {members.map((m) => (
          <li key={m.user_id}>{m.info?.name ?? m.user_id}</li>
        ))}
      </ul>
    </div>
  )
}

A user appears once however many tabs they have open, and only leaves when the last one closes. The list lives in Redis with a TTL the heartbeat refreshes, so a replica that is killed takes its members out within about a minute instead of leaving ghosts in the room forever. While the connection is down the list is empty, and it refills from a fresh snapshot when it is back, so nobody is shown who has since gone.

info is whatever the authorizer passed to SetInfo, capped at 1 KB. It is visible to every member of the channel. An email address in there is an email address you have published to the room.
8

Challenge: Show Who Is Here

Add the member list to your room. Open the same room in two browsers signed in as different users. Then open a third tab as one of them: does the count go to three? Close one of that user's two tabs: does it go down?

Typing indicators, with nothing stored

"Ada is typing" is worth telling the room right now and worth nothing a second later. It should not be a REST call, it should not be a row, and it should not go through the database at all. That is what a client event is: one browser to the others in the channel, through the hub, with the server storing nothing.

Client event (whisper): A message a subscriber sends on a channel that the hub relays to the other subscribers of that channel and nowhere else. Only private- and presence-channels take them, only from a connection already subscribed, at most ten a second per connection, with a payload under 1 KB. The sender never gets its own back.
apps/web/components/Typing.tsxtypescript
import { useChannel, useWhisper } from '@/hooks/use-realtime'

const channel = 'presence-rooms.' + room
const say = useWhisper(channel)
const [typing, setTyping] = useState<Record<string, number>>({})

useChannel(channel, {
  'client-event:typing': (p) =>
    setTyping((t) => ({ ...t, [p.user_id]: Date.now() })),
})

// Once when they start, then at most every 2s while they keep going.
const onChange = useThrottled(() => say('typing', { typing: true }), 2000)

// Nobody sends "stopped": drop anyone whose last ping is over 3s old.
const active = Object.entries(typing).filter(([, at]) => Date.now() - at < 3000)

Note what is missing: there is no stop_typing event. A browser that is closed mid-word never sends one, which is how typing indicators get stuck. Expiring on the receiver is both simpler and correct.

Throttle the send. Ten a second per connection is the server's limit, and past it your whispers are dropped and the socket gets one client_event_error with codeRATE_LIMITED per second. A refusal for any other reason arrives the same way, with PUBLIC_CHANNEL, NOT_SUBSCRIBED,INVALID_EVENT or PAYLOAD_TOO_LARGE.

user_id on a whisper is filled in by the hub from the connection's token, so you can trust who sent it. Everything in data is whatever that browser typed. Never store it, never render it as HTML, and never let it decide what someone is allowed to do.
9

Challenge: Add a Typing Indicator

Add the typing indicator to your room, throttled to one whisper every two seconds and expiring after three. Then remove the throttle and hold a key down. How many get through before the server refuses, and what does it send back?

Two replicas, and what to watch

One hub is an in-process registry, so a user on replica A would never hear a message published on replica B. The scaffolded routes.Setup already joins them through Redis wherever the project has it, which is the same Redis the cache and the job queue use, so this costs no new infrastructure.

internal/routes/routes.gogo
var realtimeOptions []realtime.Option
if cfg.Modules.Realtime {
    realtimeOptions = append(realtimeOptions, realtime.WithRedis(cfg.RedisURL, ""))
}
realtimeHub := realtime.NewHub(realtimeOptions...)

Channel publishes, presence and client events all cross the backplane. Local clients are served first and unconditionally, so a Redis outage degrades chat to one replica instead of breaking it. Delivery between replicas is best effort on purpose: the database is the record, and a client that misses an event resyncs on its next REST call.

GET /api/health carries a realtime object, and the admin's System Health page shows it as a card. Three numbers are worth watching in a chat app:

  • • messages_dropped climbing means clients cannot drain their 32 message buffer, so somebody is looking at a stale room
  • • backplane_publish_errors above zero means chat works for the people on one replica and not the others
  • • client_events_rate_limited rising usually means a typing indicator that fires on every keystroke
10

Challenge: Run Two Replicas

Start the API twice on different ports against the same Postgres and Redis. Point one browser at each. Send a message in one and watch it arrive in the other. Then stop Redis and try again: what still works, and what does /api/health say?

Summary

You have built a chat app on the hub your project already had:

  • WebSocket protocol: a persistent, two-way connection instead of polling
  • The scaffolded hub: one socket per app, authenticated with the same JWT as the REST API
  • Channels: rooms, with an authorizer that runs on every subscribe
  • REST in, channel out: the database is the record, the socket is the hint
  • Presence: a member list that survives a tab, a browser and a replica dying
  • Client events: typing indicators that never touch the database
  • Replicas and health: the Redis backplane, and the numbers that say it is working
11

Challenge: Final Challenge: A Room Switcher

Build the room list:

  1. Three rooms: general, random and help
  2. A sidebar that shows each room and how many people are in it, from usePresence
  3. Clicking one changes the channel passed to useChannel, with no new socket
  4. An authorizer that only lets a user into the rooms they are a member of
12

Challenge: Final Challenge: Read Receipts

Add read receipts, and decide for each piece whether it is a row or a whisper:

  1. "Seen by Ada" under the last message, updating live
  2. It has to survive a refresh, so something is stored
  3. It should not cost a write per scroll event, so something is whispered
  4. Write down which is which before you build it, then check your answer against what the hub charges you for each