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
xmppsubprotocol, 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:
-
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 thepassargument as the token when theX-OAUTH2orOAUTHBEARERmechanism 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); -
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/client0.14 implements SASL2, Bind 2 and FAST (theHT-SHA-256-NONEmechanism), and itscredentialsoption receives thefasthelper, 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 aniqand resolves with theresultstanza or rejects with the error condition, so archive queries and discovery read as ordinaryawaitcode. -
Middleware and callees.
xmpp.iqCallee.get(namespace, name, handler)answers incomingiqrequests (for example an XMPP Ping or a Jingle request) andxmpp.middlewarelets you add your own stanza processing. -
Reconnect.
xmpp.reconnectre-establishes the connection after an error with a configurabledelay(milliseconds). A cleanxmpp.stop()does not reconnect. -
Stream Management. It is on by default when the server offers it and emits
resumed,ackandfailevents onxmpp.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 assid. That archive id is the nearest equivalent of the per-roomseq: 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/clientresumes the session through Stream Management on a brief drop. After a longer outage, or when the server no longer has the session, calljoinagain 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.CONNFAILor an immediate close onwss://. The proxy does not passUpgradeheaders, the certificate does not match, or the server needsconsider_websocket_securebehind a TLS-terminating proxy. -
not-authorizedorconflicton 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 needswsand@xmldom/xmldom. -
Rebuilding stanzas with string concatenation. Interpolating user text into XML opens stanza injection. Use
stx, the builders orxml()(they escape). -
Waiting for a reply that never comes. Always pass a timeout to
sendIQoriqCaller.request. -
Reconnect storms. Add jitter to the reconnect delay (see Connection Lifecycle, Heartbeats and Reconnection).