XMPP in the Browser

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

A page that talks XMPP needs a client library: it has to frame the stream, negotiate TLS-protected authentication, keep the connection alive, resume it after a drop, and give the application a convenient way to build and receive stanzas. This page covers the two libraries most used in the browser today, Strophe.js 5 and @xmpp/client, mentions Stanza, and ends with a complete browser client for the XMPP variant of Chatterbox that implements the same features as the custom protocol of Message Design and Serialization. The protocol and server background is on XMPP Fundamentals and XMPP Servers.

Versions were checked on 2026-10-24: Strophe.js 5.0.0 (published 2026-07-21, MIT), @xmpp/client 0.14.0 (2025-10-29, ISC) and Stanza 12.22.1 (2026-03-28). The examples target the ejabberd 26.09 setup from the server page; the Strophe.js examples work in current browsers and in Node.js 24, the @xmpp/client ones in browsers and Node.js 24.

Connection methods: WebSocket, BOSH and discovery

A browser cannot open a TCP socket, so XMPP reaches a page in one of two ways:

  • WebSocket (RFC 7395): one WebSocket per XMPP connection, the xmpp subprotocol, one XML element per WebSocket text message, and <open/> and <close/> framing elements instead of an unbounded <stream:stream> (the capture is on the fundamentals page). It is the default for every library below and for Converse.js.

  • BOSH (XEP-0124 with XEP-0206): the stream is emulated over long-polling HTTP requests. It is the fallback for networks or proxies that break WebSocket, it costs more requests and latency, and it is supported by Strophe.js, Converse.js and by every server in the server comparison. The server page shows how to enable it.

Discovery (XEP-0156) lets a page know only the XMPP domain. The client requests https://chat.example.test/.well-known/host-meta (or host-meta.json) and reads the urn:xmpp:alt-connections:websocket and urn:xmpp:alt-connections:xbosh links. Strophe.js and @xmpp/client do not do this for you; Converse.js does (and requests the file by default, which is why it logs a failed lookup if the file is missing). A small helper:

export async function discoverEndpoints(domain) {
  const res = await fetch(`https://${domain}/.well-known/host-meta.json`, { headers: { Accept: "application/json" } });
  if (!res.ok) throw new Error(`host-meta returned ${res.status}`);
  const { links } = await res.json();
  const pick = (rel) => links.find((l) => l.rel === rel)?.href ?? null;
  return { websocket: pick("urn:xmpp:alt-connections:websocket"), bosh: pick("urn:xmpp:alt-connections:xbosh") };
}

Source: XEP-0156: Discovering Alternative XMPP Connection Methods. The endpoint must send CORS headers when the page is on another origin, and the XRD document and its JSON form are specified by RFC 6415. Use wss:// URLs everywhere except a loopback development server.

Strophe.js 5

Strophe.js is the oldest browser XMPP library (2008) and the one Converse.js and Jitsi are built on. Version 5 is written in TypeScript, has no browser dependencies, offers WebSocket, BOSH and shared-worker transports, native Stream Management and the SASL mechanisms SCRAM-SHA-1, -256, -384 and -512, PLAIN, EXTERNAL, ANONYMOUS, OAUTHBEARER and X-OAUTH2.

Install it with npm install strophe.js, or load dist/strophe.umd.min.js from a CDN, which exposes Strophe, stx, $iq, $msg, $pres and $build as globals. In Node.js, version 5 needs ws and @xmldom/xmldom instead of the jsdom of version 4 (a breaking change of 5.0.0 for Node.js only; browsers are unaffected).

Connecting and the status callback

import { Strophe, stx } from "strophe.js";

const conn = new Strophe.Connection("wss://chat.example.test/ws", {
  enableStreamManagement: true,
  streamManagement: { requestResume: true },
});

conn.connect("alice@chat.example.test/web", "change-me-alice", (status) => {
  switch (status) {
    case Strophe.Status.CONNECTING:     console.log("connecting"); break;
    case Strophe.Status.AUTHENTICATING: console.log("authenticating"); break;
    case Strophe.Status.AUTHFAIL:       console.log("wrong credentials"); break;
    case Strophe.Status.CONNFAIL:       console.log("cannot reach the server"); break;
    case Strophe.Status.DISCONNECTED:   console.log("closed; schedule a reconnect"); break;
    case Strophe.Status.CONNECTED:
      console.log("bound as", conn.jid, "resumed:", conn.hasResumed());
      conn.send(stx`<presence xmlns="jabber:client"/>`);
      break;
  }
});

Source: Strophe.js repository: README. The status constants are ERROR (0), CONNECTING, CONNFAIL, AUTHENTICATING, AUTHFAIL, CONNECTED, DISCONNECTED, DISCONNECTING, ATTACHED, REDIRECT, CONNTIMEOUT, BINDREQUIRED, ATTACHFAIL and RECONNECTING. Strophe does not reconnect by itself after DISCONNECTED; the application decides (see the Chatterbox client below).

Building and receiving stanzas

stx is a tagged template literal that parses the XML you write and escapes every interpolated value, so it is safe against stanza injection:

const text = "<hi & bye>";   // user input, contains markup characters
conn.send(stx`<message to="bob@chat.example.test" type="chat" xmlns="jabber:client"><body>${text}</body></message>`);

A browser run delivered the body to the other account as the literal text <hi & bye>, escaped on the wire. The older builder API, $msg, $pres, $iq and $build, remains supported and produces the same elements:

import { $msg, $iq } from "strophe.js";

conn.send($msg({ to: "bob@chat.example.test", type: "chat" }).c("body").t("Hello, Chatterbox"));
conn.sendIQ(
  $iq({ type: "get" }).c("query", { xmlns: "jabber:iq:roster" }),
  (result) => console.log("roster", result.querySelectorAll("item").length),
  (error) => console.warn("roster failed", error),
  5000, // timeout in ms
);

Incoming stanzas are routed with handlers:

// addHandler(callback, namespace, name, type, id, from); null matches anything.
const ref = conn.addHandler((stanza) => {
  const body = stanza.querySelector("body")?.textContent;
  if (body) console.log(stanza.getAttribute("from"), "says", body);
  return true;   // true keeps the handler, false removes it after this call
}, null, "message", "chat");

conn.deleteHandler(ref);

Source: Strophe.js repository (sections "Creating stanzas" and "Handling incoming stanzas"). Handlers receive DOM Element objects, so querySelector and getAttribute work as in any HTML page. sendIQ calls the success or the error callback exactly once, or the error callback with null on timeout; use it instead of tracking id values yourself.

Plugins

Strophe.js has a small plugin system for reusable protocol code, registered with Strophe.addConnectionPlugin(name, prototype); the prototype receives the connection in its init method and is then available as conn.name. Plugins are how older projects packaged things such as roster, MUC and pub/sub helpers. The npm package strophe.js itself carries only the core; when you need MUC, MAM or file upload, either write the stanzas as shown on XMPP Chat Features or use Converse.js (Converse.js), whose @converse/headless package is a complete XMPP client engine built on Strophe.

Stream Management and sharing a connection

With enableStreamManagement: true Strophe.js 5 negotiates XEP-0198 on WebSocket connections, acknowledges stanzas and stores the resumable session in sessionStorage (replaceable through streamManagement.storage). After Status.CONNECTED, conn.hasResumed() tells whether the old session came back; when it did, skip the roster fetch and the room joins. The README also documents a worker option that shares one WebSocket (and one Stream Management session) between all tabs through a SharedWorker (dist/shared-connection-worker.js): one tab becomes primary, the others secondary, and the role moves when the primary tab closes. That keeps a chat from holding five connections and five presences for a user with five tabs open.

Tokens instead of passwords

A page should not keep a user’s password in localStorage. Two patterns avoid it:

  1. Short-lived token as the password. The application server authenticates the user (session cookie, OIDC) and hands the page a token for the XMPP server, which accepts it as an OAuth bearer token (ejabberd advertises X-OAUTH2, as the capture on the fundamentals page shows, and validates tokens through its OAuth support). Strophe.js sends the pass argument as the token when the X-OAUTH2 or OAUTHBEARER mechanism is chosen. Because Strophe prefers the mechanism with the highest priority and SCRAM outranks the token mechanisms, restrict the list when you log in with a token:

    const conn = new Strophe.Connection("wss://chat.example.test/ws", { mechanisms: [Strophe.SASLXOAuth2] });
    conn.connect("alice@chat.example.test", accessToken, onStatus);
  2. FAST (XEP-0484). The first login uses SCRAM; the server issues a token that the library stores and presents instead of the password afterwards. @xmpp/client 0.14 implements SASL2, Bind 2 and FAST (the HT-SHA-256-NONE mechanism), and its credentials option receives the fast helper, so the token lives in the library’s storage rather than in your code. Server and client support are both still uneven; check the stream features first.

Whichever pattern you use, the token must be short lived, tied to the user, and revocable server side. See Authentication and Authorization.

@xmpp/client 0.14

@xmpp/client is the maintained successor of the old node-xmpp; it is an ES module with an async API built from small packages (@xmpp/websocket, @xmpp/sasl2, @xmpp/stream-management, @xmpp/reconnect, @xmpp/iq, @xmpp/jid, @xmpp/xml). It runs in the browser and in Node.js 24 (it uses the platform WebSocket), and ships a pre-built bundle (dist/xmpp.min.js) for plain <script> use.

import { client, xml } from "@xmpp/client";

const xmpp = client({
  service: "wss://chat.example.test/ws",
  domain: "chat.example.test",
  username: "alice",
  password: "change-me-alice",
  resource: "web",
});

xmpp.on("online", async (address) => {
  console.log("online as", address.toString());
  await xmpp.send(xml("presence"));
});
xmpp.on("stanza", (stanza) => {
  if (stanza.is("message") && stanza.getChild("body")) console.log(stanza.attrs.from, stanza.getChildText("body"));
});
xmpp.on("offline", () => console.log("offline"));
xmpp.on("error", (err) => console.error(err));

await xmpp.start();
await xmpp.send(xml("message", { to: "bob@chat.example.test", type: "chat" }, xml("body", {}, "Hello, Chatterbox")));

Source: xmpp.js repository and xmpp.js documentation. The runs used SCRAM-SHA-1 on ws://localhost: @xmpp/client 0.14 includes SCRAM-SHA-1, PLAIN, ANONYMOUS and HT-SHA-256-NONE, so a server must keep SCRAM-SHA-1 enabled (there is no SCRAM-SHA-256 in this version), and it refuses PLAIN on an insecure ws:// URL. Over wss:// it uses the first mechanism the server lists.

Things that distinguish it from Strophe.js:

  • Promises. start(), send(), stop() and request/response helpers return promises. xmpp.iqCaller.request(stanza, timeout) sends an iq and resolves with the result stanza or rejects with the error condition, so archive queries and discovery read as ordinary await code.

  • Middleware and callees. xmpp.iqCallee.get(namespace, name, handler) answers incoming iq requests (for example an XMPP Ping or a Jingle request) and xmpp.middleware lets you add your own stanza processing.

  • Reconnect. xmpp.reconnect re-establishes the connection after an error with a configurable delay (milliseconds). A clean xmpp.stop() does not reconnect.

  • Stream Management. It is on by default when the server offers it and emits resumed, ack and fail events on xmpp.streamManagement.

An abrupt TCP failure triggered reconnect and resume in a test: a small TCP proxy sat between the client and ejabberd, the proxy destroyed the socket, the client emitted disconnect, reconnected, and xmpp.streamManagement emitted resumed (a graceful xmpp.stop() ends the session instead, and a normal WebSocket close frame does not trigger a reconnect).

Stanza

Stanza (stanza on npm, 12.22.1) is a TypeScript library that wraps many XEPs in typed JavaScript objects (client.sendMessage({ to, body }), client.joinRoom(jid, nick), client.searchHistory(jid, options)), so that you rarely see XML. It trades control for convenience: it is a good choice when you want typed MUC and MAM helpers and accept its object model, and a poor one when you need a stanza it has no object for. Chatterbox here builds its stanzas explicitly so that the wire format stays visible.

The Chatterbox XMPP client

The module below is the browser client of the XMPP variant of Chatterbox. It uses @xmpp/client, exposes the same operations as the custom WebSocket client of Building a Chat App (connect, join, send, edit, react, typing, read markers, history, attachments) and reports events in the same shape, so the user interface does not care which transport it uses. The features themselves are explained, with their stanzas, on XMPP Chat Features.

import { client, xml } from "@xmpp/client";
import { randomUUID } from "node:crypto";

const NS = {
  muc: "http://jabber.org/protocol/muc",
  rsm: "http://jabber.org/protocol/rsm",
  mam: "urn:xmpp:mam:2",
  sid: "urn:xmpp:sid:0",
  forward: "urn:xmpp:forward:0",
  chatstates: "http://jabber.org/protocol/chatstates",
  correct: "urn:xmpp:message-correct:0",
  reactions: "urn:xmpp:reactions:0",
  markers: "urn:xmpp:chat-markers:0",
  upload: "urn:xmpp:http:upload:0",
};

export class ChatterboxXmpp {
  #xmpp;
  #domain;
  #mucDomain;
  #nick;
  onEvent = () => {};

  constructor({ service, domain, username, password, resource = "web" }) {
    this.#domain = domain;
    this.#mucDomain = `conference.${domain}`;
    this.#nick = username;
    this.#xmpp = client({ service, domain, username, password, resource });
    this.#xmpp.on("stanza", (s) => this.#route(s));
    this.#xmpp.on("error", (e) => this.onEvent({ type: "error", message: String(e.message ?? e) }));
    this.#xmpp.streamManagement.on("resumed", () => this.onEvent({ type: "resumed" }));
  }

  async connect() {
    await new Promise((resolve, reject) => {
      this.#xmpp.once("online", resolve);
      this.#xmpp.once("error", reject);
      this.#xmpp.start().catch(reject);
    });
    await this.#xmpp.send(xml("presence"));
  }

  disconnect() { return this.#xmpp.stop(); }

  roomJid(room) { return `${room}@${this.#mucDomain}`; }

  join(room, { maxstanzas = 0 } = {}) {
    return this.#xmpp.send(
      xml("presence", { to: `${this.roomJid(room)}/${this.#nick}` },
        xml("x", { xmlns: NS.muc }, xml("history", { maxstanzas: String(maxstanzas) }))));
  }

  leave(room) {
    return this.#xmpp.send(xml("presence", { to: `${this.roomJid(room)}/${this.#nick}`, type: "unavailable" }));
  }

  async sendMessage(room, text, { replyTo = null } = {}) {
    const id = randomUUID();
    const msg = xml("message", { to: this.roomJid(room), type: "groupchat", id },
      xml("body", {}, text), xml("markable", { xmlns: NS.markers }));
    if (replyTo) msg.append(xml("reply", { xmlns: "urn:xmpp:reply:0", to: `${this.roomJid(room)}/${replyTo.nick}`, id: replyTo.id }));
    await this.#xmpp.send(msg);
    return id;
  }

  edit(room, targetId, text) {
    return this.#xmpp.send(xml("message", { to: this.roomJid(room), type: "groupchat", id: randomUUID() },
      xml("body", {}, text), xml("replace", { xmlns: NS.correct, id: targetId })));
  }

  react(room, targetId, emojis) {
    return this.#xmpp.send(xml("message", { to: this.roomJid(room), type: "groupchat", id: randomUUID() },
      xml("reactions", { xmlns: NS.reactions, id: targetId }, ...emojis.map((e) => xml("reaction", {}, e))),
      xml("store", { xmlns: "urn:xmpp:hints" })));
  }

  typing(room, state) {
    return this.#xmpp.send(xml("message", { to: this.roomJid(room), type: "groupchat" },
      xml(state === "start" ? "composing" : "paused", { xmlns: NS.chatstates })));
  }

  displayed(room, targetId) {
    return this.#xmpp.send(xml("message", { to: this.roomJid(room), type: "groupchat" },
      xml("displayed", { xmlns: NS.markers, id: targetId })));
  }

  // One page of history, newest first when before is empty. Returns messages in ascending order.
  async history(room, { before = "", limit = 50 } = {}) {
    const queryid = randomUUID();
    const messages = [];
    const collect = (s) => {
      const r = s.is("message") && s.getChild("result", NS.mam);
      if (!r || r.attrs.queryid !== queryid) return;
      const m = r.getChild("forwarded", NS.forward).getChild("message");
      messages.push({ archiveId: r.attrs.id, id: m.attrs.id, from: m.attrs.from, text: m.getChildText("body") });
    };
    this.#xmpp.on("stanza", collect);
    try {
      const res = await this.#xmpp.iqCaller.request(
        xml("iq", { type: "set", to: this.roomJid(room) },
          xml("query", { xmlns: NS.mam, queryid },
            xml("set", { xmlns: NS.rsm }, xml("max", {}, String(limit)), xml("before", {}, before)))), 15000);
      const fin = res.getChild("fin", NS.mam);
      const first = fin.getChild("set", NS.rsm)?.getChildText("first") ?? null;
      return { messages, hasMore: fin.attrs.complete !== "true", next: first };
    } finally {
      this.#xmpp.off("stanza", collect);
    }
  }

  async uploadFile(name, bytes, mime) {
    const slot = await this.#xmpp.iqCaller.request(
      xml("iq", { type: "get", to: `upload.${this.#domain}` },
        xml("request", { xmlns: NS.upload, filename: name, size: String(bytes.length), "content-type": mime })));
    const put = slot.getChild("slot", NS.upload).getChild("put");
    const headers = { "Content-Type": mime };
    for (const h of put.getChildren("header")) headers[h.attrs.name] = h.text();
    const res = await fetch(put.attrs.url, { method: "PUT", body: bytes, headers });
    if (!res.ok) throw new Error(`upload failed: ${res.status}`);
    return slot.getChild("slot", NS.upload).getChild("get").attrs.url;
  }

  #route(s) {
    if (!s.is("message") && !s.is("presence")) return;
    if (s.is("presence")) {
      const from = s.attrs.from ?? "";
      if (from.includes("@conference.")) this.onEvent({ type: "presence", room: from.split("@")[0], nick: from.split("/")[1], status: s.attrs.type === "unavailable" ? "offline" : "online" });
      return;
    }
    const room = (s.attrs.from ?? "").split("@")[0];
    const sid = s.getChild("stanza-id", NS.sid)?.attrs.id ?? null;
    if (s.getChild("replace", NS.correct)) this.onEvent({ type: "edit", room, target: s.getChild("replace").attrs.id, text: s.getChildText("body"), sid });
    else if (s.getChild("reactions", NS.reactions)) this.onEvent({ type: "react", room, target: s.getChild("reactions").attrs.id, emojis: s.getChild("reactions").getChildren("reaction").map((r) => r.text()), from: s.attrs.from });
    else if (s.getChild("body")) this.onEvent({ type: "message", room, id: s.attrs.id, from: s.attrs.from, text: s.getChildText("body"), sid });
    else if (s.getChild("composing", NS.chatstates) || s.getChild("paused", NS.chatstates)) this.onEvent({ type: "typing", room, from: s.attrs.from, state: s.getChild("composing", NS.chatstates) ? "start" : "stop" });
    else if (s.getChild("displayed", NS.markers)) this.onEvent({ type: "receipt", room, from: s.attrs.from, kind: "read", target: s.getChild("displayed").attrs.id });
  }
}

Source: XEP-0045, XEP-0313, XEP-0308, XEP-0444, XEP-0085, XEP-0333 and XEP-0363. With two accounts in a room against the ejabberd setup of the server page, a message followed by an edit, a reaction, a typing state and a displayed marker produced the events message, edit, react, typing and receipt. history returned the newest page first (hasMore true), then the page before it, and an uploaded file was readable at the returned URL.

Usage in the page:

import { ChatterboxXmpp } from "./chatterbox-xmpp.js";

const chat = new ChatterboxXmpp({
  service: "wss://chat.example.test/ws", domain: "chat.example.test", username: "alice", password: "change-me-alice",
});
chat.onEvent = (e) => render(e);          // message, edit, react, typing, receipt, presence, resumed, error
await chat.connect();
await chat.join("lobby");
const id = await chat.sendMessage("lobby", "Hello, Chatterbox");
await chat.edit("lobby", id, "Hello, Chatterbox!");
const page = await chat.history("lobby", { limit: 50 });   // newest page; call again with before: page.next

How the pieces differ from the custom protocol:

  • Message identity. The sender chooses the message id (randomUUID()), exactly as in Chatterbox v1. The server also stamps every archived message with a stanza id (XEP-0359), the archive id returned as sid. That archive id is the nearest equivalent of the per-room seq: it is monotonic within the archive and is what history paginates on.

  • Acknowledgement. A room reflects the sender’s own message back, and that reflection (with the stanza id) is the application-level acknowledgement. Stream Management acks tell you the server received the stanza.

  • Reconnecting. @xmpp/client resumes the session through Stream Management on a brief drop. After a longer outage, or when the server no longer has the session, call join again and fetch the messages after the last archive id.

  • History pages include non-text messages. An archive page returned a reaction and an edit among the messages (the reaction has no body). The client must apply corrections and reactions to the messages they target and drop empty ones when it renders.

Common errors and pitfalls

  • No stanza arrives and no error appears. The page never sent initial presence. Both libraries leave that to you.

  • Status.CONNFAIL or an immediate close on wss://. The proxy does not pass Upgrade headers, the certificate does not match, or the server needs consider_websocket_secure behind a TLS-terminating proxy.

  • not-authorized or conflict on bind. Two connections used the same resource; use a random resource per tab, or the SharedWorker option.

  • Password prompts in the page. Use tokens, as above, and never put the password in the URL.

  • Strophe.js in Node.js fails with ERR_MODULE_NOT_FOUND. Version 5 needs ws and @xmldom/xmldom.

  • Rebuilding stanzas with string concatenation. Interpolating user text into XML opens stanza injection. Use stx, the builders or xml() (they escape).

  • Waiting for a reply that never comes. Always pass a timeout to sendIQ or iqCaller.request.

  • Reconnect storms. Add jitter to the reconnect delay (see Connection Lifecycle, Heartbeats and Reconnection).