Skip to main content
Technology & EngineeringMessaging Services344 lines

Ably

"Ably: real-time messaging, pub/sub, presence, message history, push notifications, WebSocket/SSE"

Quick Summary18 lines
Ably is a real-time messaging platform built on a global edge network that guarantees message ordering, delivery, and exactly-once semantics. Unlike simpler WebSocket wrappers, Ably provides persistent message history, automatic connection recovery with continuity, and multi-protocol support (WebSocket, SSE, MQTT, REST). Design your application around Ably's channel model where channels are the unit of message routing. Use the Realtime client for live subscriptions and the REST client for server-side publishing where you do not need a persistent connection. Ably's connection state recovery means clients automatically catch up on missed messages after brief disconnections — lean into this rather than building your own recovery logic.

## Key Points

- Use token authentication for all client connections; never expose API keys in browser code
- Scope token capabilities tightly — grant only the channels and operations each client needs
- Separate high-frequency ephemeral data (typing indicators, cursor positions) onto dedicated channels to avoid polluting message history
- Use the `rewind` parameter to backfill messages on subscribe rather than making a separate history request
- Handle the `suspended` connection state explicitly — after 2 minutes disconnected, Ably cannot guarantee message continuity; fetch state from your server
- Use REST client on the server for fire-and-forget publishes; Realtime client is only needed when the server subscribes to channels
- Set appropriate TTL on channel messages via channel rules; do not persist ephemeral data like typing indicators
- Use Ably's `idempotentRestPublishing` option to safely retry failed publishes without duplicates
- **Exposing API keys to clients** — API keys grant full access; always use token auth for browsers and mobile apps
- **One channel per user pair for DMs** — This creates channel sprawl; use a single channel per conversation with a consistent naming scheme
- **Ignoring connection state recovery** — Ably auto-recovers connections and replays missed messages; building custom reconnection logic on top conflicts with this
- **Publishing from clients without server validation** — Allow clients to publish only after your server validates the action; use capabilities to restrict publish access where needed
skilldb get messaging-services-skills/AblyFull skill: 344 lines
Paste into your CLAUDE.md or agent config

Ably Real-Time Messaging

Core Philosophy

Ably is a real-time messaging platform built on a global edge network that guarantees message ordering, delivery, and exactly-once semantics. Unlike simpler WebSocket wrappers, Ably provides persistent message history, automatic connection recovery with continuity, and multi-protocol support (WebSocket, SSE, MQTT, REST). Design your application around Ably's channel model where channels are the unit of message routing. Use the Realtime client for live subscriptions and the REST client for server-side publishing where you do not need a persistent connection. Ably's connection state recovery means clients automatically catch up on missed messages after brief disconnections — lean into this rather than building your own recovery logic.

Setup

Server-Side REST Client

import Ably from "ably";

// REST client for server-side publishing (no persistent connection)
const ablyRest = new Ably.Rest({ key: process.env.ABLY_API_KEY! });

// Publish a message
async function publishMessage(channelName: string, event: string, data: unknown) {
  const channel = ablyRest.channels.get(channelName);
  await channel.publish(event, data);
}

Client-Side Realtime Setup

import Ably from "ably";
import { AblyProvider, useChannel, usePresence } from "ably/react";

// Token-based auth (recommended for clients)
const ablyClient = new Ably.Realtime({
  authUrl: "/api/ably/token",
  authMethod: "POST",
});

function App() {
  return (
    <AblyProvider client={ablyClient}>
      <ChatApplication />
    </AblyProvider>
  );
}

Token Authentication Endpoint

import express from "express";
import Ably from "ably";

const app = express();
const ablyRest = new Ably.Rest({ key: process.env.ABLY_API_KEY! });

app.post("/api/ably/token", async (req, res) => {
  const userId = req.session?.userId;
  if (!userId) {
    res.status(401).json({ error: "Unauthorized" });
    return;
  }

  const tokenRequest = await ablyRest.auth.createTokenRequest({
    clientId: userId,
    capability: {
      [`private:${userId}`]: ["subscribe", "publish", "presence"],
      "public:*": ["subscribe"],
      "chat:*": ["subscribe", "publish", "presence", "history"],
    },
  });

  res.json(tokenRequest);
});

Key Techniques

Publishing and Subscribing

// Server-side: publish with metadata
async function publishChatMessage(roomId: string, message: {
  id: string;
  userId: string;
  text: string;
  timestamp: number;
}) {
  const channel = ablyRest.channels.get(`chat:${roomId}`);
  await channel.publish("message", message);
}

// Publish multiple messages atomically
async function publishBatch(channelName: string, messages: { name: string; data: unknown }[]) {
  const channel = ablyRest.channels.get(channelName);
  await channel.publish(messages);
}

// Client-side React: subscribe to a channel
function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<ChatMessage[]>([]);

  const { channel } = useChannel(`chat:${roomId}`, "message", (msg) => {
    setMessages((prev) => [...prev, msg.data as ChatMessage]);
  });

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.userId}</strong>: {m.text}
        </div>
      ))}
    </div>
  );
}

Presence for Online Status

function OnlineUsers({ roomId }: { roomId: string }) {
  const { presenceData, updateStatus } = usePresence<{
    name: string;
    status: string;
  }>(`chat:${roomId}`, { name: "Current User", status: "active" });

  const setAway = () => {
    updateStatus({ name: "Current User", status: "away" });
  };

  return (
    <div>
      <h3>Online ({presenceData.length})</h3>
      {presenceData.map((member) => (
        <div key={member.clientId}>
          {member.data?.name} — {member.data?.status}
        </div>
      ))}
      <button onClick={setAway}>Set Away</button>
    </div>
  );
}

// Server-side: query presence without subscribing
async function getOnlineUsers(channelName: string) {
  const channel = ablyRest.channels.get(channelName);
  const members = await channel.presence.get();
  return members.map((m) => ({
    clientId: m.clientId,
    data: m.data,
    connectionId: m.connectionId,
  }));
}

Message History and Rewind

// Fetch historical messages via REST
async function getMessageHistory(channelName: string, limit: number = 50) {
  const channel = ablyRest.channels.get(channelName);
  const history = await channel.history({ limit, direction: "backwards" });

  const messages = history.items.map((msg) => ({
    id: msg.id,
    event: msg.name,
    data: msg.data,
    timestamp: msg.timestamp,
  }));

  // Paginate if needed
  if (history.hasNext()) {
    const nextPage = await history.next();
    // process next page
  }

  return messages;
}

// Subscribe with rewind — get last N messages on attach
function ChatWithHistory({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<ChatMessage[]>([]);

  const { channel } = useChannel(
    { channelName: `chat:${roomId}`, options: { params: { rewind: "50" } } },
    "message",
    (msg) => {
      setMessages((prev) => [...prev, msg.data as ChatMessage]);
    }
  );

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>{m.userId}: {m.text}</div>
      ))}
    </div>
  );
}

Connection State Management

function ConnectionStatus() {
  const [status, setStatus] = useState<string>("initialized");

  useEffect(() => {
    const handleStateChange = (stateChange: Ably.ConnectionStateChange) => {
      setStatus(stateChange.current);

      if (stateChange.current === "disconnected") {
        console.log("Disconnected, will auto-reconnect...");
      }
      if (stateChange.current === "suspended") {
        console.log("Connection suspended — message continuity may be lost");
      }
      if (stateChange.current === "failed") {
        console.error("Connection failed:", stateChange.reason);
      }
    };

    ablyClient.connection.on(handleStateChange);
    return () => {
      ablyClient.connection.off(handleStateChange);
    };
  }, []);

  return <div className={`connection-${status}`}>Status: {status}</div>;
}

Channel Rules and Namespaces

// Use namespaced channels for organized access control
// Configure channel rules in Ably dashboard or via Control API

// Public broadcast — no auth needed
const publicChannel = "public:announcements";

// Private user channel — token capability restricts access
const userChannel = `private:${userId}`;

// Room-scoped chat — wildcard capability "chat:*"
const chatChannel = `chat:${roomId}`;

// Typing indicators on a separate channel to avoid noise
const typingChannel = `chat:${roomId}:typing`;

async function publishTypingIndicator(roomId: string, userId: string, isTyping: boolean) {
  const channel = ablyRest.channels.get(`chat:${roomId}:typing`);
  await channel.publish("typing", { userId, isTyping });
}

Push Notifications via Ably

// Server-side: register a device for push
async function registerDeviceForPush(deviceId: string, platform: "gcm" | "apns" | "web") {
  await ablyRest.push.admin.deviceRegistrations.save({
    id: deviceId,
    platform,
    formFactor: "phone",
    push: {
      recipient:
        platform === "gcm"
          ? { transportType: "gcm", registrationToken: "device-token-here" }
          : { transportType: "apns", deviceToken: "device-token-here" },
    },
  });
}

// Publish with push notification payload
async function publishWithPush(channelName: string, data: unknown, pushTitle: string) {
  const channel = ablyRest.channels.get(channelName);
  await channel.publish({
    name: "alert",
    data,
    extras: {
      push: {
        notification: {
          title: pushTitle,
          body: typeof data === "string" ? data : JSON.stringify(data),
        },
      },
    },
  });
}

Server-Sent Events Fallback

// For environments where WebSocket is not available, Ably supports SSE
// No SDK needed — plain HTTP endpoint
app.get("/api/sse/channel/:name", (req, res) => {
  const channelName = req.params.name;
  const sseUrl = `https://realtime.ably.io/sse?channels=${channelName}&v=1.2&key=${process.env.ABLY_API_KEY}`;

  // Proxy SSE from Ably to client
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  fetch(sseUrl).then(async (response) => {
    if (!response.body) return;
    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      res.write(decoder.decode(value));
    }
  });
});

Best Practices

  • Use token authentication for all client connections; never expose API keys in browser code
  • Scope token capabilities tightly — grant only the channels and operations each client needs
  • Separate high-frequency ephemeral data (typing indicators, cursor positions) onto dedicated channels to avoid polluting message history
  • Use the rewind parameter to backfill messages on subscribe rather than making a separate history request
  • Handle the suspended connection state explicitly — after 2 minutes disconnected, Ably cannot guarantee message continuity; fetch state from your server
  • Use REST client on the server for fire-and-forget publishes; Realtime client is only needed when the server subscribes to channels
  • Set appropriate TTL on channel messages via channel rules; do not persist ephemeral data like typing indicators
  • Use Ably's idempotentRestPublishing option to safely retry failed publishes without duplicates

Anti-Patterns

  • Exposing API keys to clients — API keys grant full access; always use token auth for browsers and mobile apps
  • One channel per user pair for DMs — This creates channel sprawl; use a single channel per conversation with a consistent naming scheme
  • Ignoring connection state recovery — Ably auto-recovers connections and replays missed messages; building custom reconnection logic on top conflicts with this
  • Publishing from clients without server validation — Allow clients to publish only after your server validates the action; use capabilities to restrict publish access where needed
  • Using history as a primary database — Message history is meant for catch-up, not long-term storage; persist important data in your own database
  • Subscribing to wildcard channels for everything — Each subscription consumes resources; subscribe only to channels the user actively needs
  • Not unsubscribing from unused channels — Detach from channels when the user navigates away to free up connection resources and reduce unnecessary traffic

Install this skill directly: skilldb add messaging-services-skills

Get CLI access →