Building a Better WhatsApp SDK
A week ago I wrote down why a WhatsApp bot costs so much more than a Telegram one. WhatsApp has no front door for a normal account, so whatsapp-web.js runs a real copy of Chromium, loads the actual web client, and taps keys like a person with very fast hands. I ended that piece calling the browser a stand-in for an API nobody was ever given, and folding WhatsApp's "no" into a headless page you try not to look at. Then I went and looked at the other door.
The other door does exist. WhatsApp's Multi-Device protocol is a real thing: your phone and your linked laptops each hold their own Signal keys and each open their own encrypted WebSocket to WhatsApp's servers. A linked laptop is not scraping a web page; it is a first-class client speaking a binary protocol. Baileys is a TypeScript implementation of exactly that client, and it needs no browser at all. So the question that started this project was simple: if I skip the browser and speak the protocol myself, what do I actually save, and what do I have to pay instead?
Skipping the browser is not free#
The saving is easy to state. A headless Chromium driving WhatsApp Web is a few hundred megabytes of resident memory and a slow, flaky boot: it has to launch a browser, load a page, and log a session in before your first message moves. Baileys opens a socket. On my machine that is the difference between waiting for Chrome and waiting for a TCP handshake, and between a container that needs half a gig to breathe and one that runs in tens of megabytes.
The catch is that the browser was doing more than rendering. When whatsapp-web.js hands you client.on('message', ...), a whole page has already turned raw protocol frames into a tidy message object for you. Take the browser away and that work does not vanish; it lands on you. Baileys is deliberately low-level. It gives you the socket, the auth state, and an event emitter that fires messages.upsert with the raw WhatsApp message node, protobuf fields and all. It is faithful to the protocol, which is the point, and it is not trying to be a bot framework, which is also the point.
So the browserless door is not a free upgrade. It is a swap: a browser's memory for a protocol's ergonomics. whatsweb is the wager that the second bill is smaller than the first, and that most of it can be paid once, in a library, instead of in every bot.
A protocol is not a bot#
Here is the same "ping/pong" bot, the one the last post wrote with a headless browser, written on the socket instead:
import { createClient } from '@whatsweb/core';
const wa = createClient({ session: 'my-bot' });
wa.on('ready', (me) => console.log('connected:', me.number));
wa.command('ping', (ctx) => ctx.reply('pong'));
wa.hears(/hello/i, (ctx) => ctx.reply('hi there'));
await wa.start();There is no qr boilerplate in sight, no page to wait on, and no msg.body === '!ping' string match. That is the whole job of the SDK: everything between the socket and those two handlers. whatsweb is a thin, opinionated layer over Baileys that keeps the low-level socket reachable but stops you from touching it for the ninety percent of a bot that is the same every time.
The rest of this post is the four pieces of that middle layer that took the most thought: the router, the firehose, the media, and the conversation.
The router is one good idea, borrowed#
The most useful thing a bot framework gives you is a way to say "when the message looks like this, run that", and to stack cross-cutting rules in front of every handler. Telegraf and Koa settled the shape years ago: a middleware chain where each layer gets the context and a next it can choose to call. I took it wholesale.
wa.command('ping', (ctx) => ctx.reply('pong'));
wa.command(['echo', 'say'], (ctx) => ctx.reply(ctx.args.join(' ')));
wa.hears(/price (\d+)/i, (ctx) => ctx.reply(`that costs ${ctx.match![1]}`));
wa.use(async (ctx, next) => {
if (ctx.isGroup) return; // ignore groups: don't call next()
await next();
});The part I like is that command and hears are not special machinery. They are sugar over use. A command is a middleware that checks the parsed command name, runs the handler if it matches, and otherwise falls through to next:
command(name: string | string[], handler: Handler): this {
const names = (Array.isArray(name) ? name : [name]).map((n) => n.toLowerCase());
return this.use(async (ctx, next) => {
if (ctx.command && names.includes(ctx.command)) await handler(ctx);
else await next();
});
}That means there is exactly one dispatch path to reason about, and one place a bug can hide. Every incoming message walks the same chain in registration order, and a handler that does not call next ends the walk. The runner even tracks the last index it dispatched, so calling next twice in one middleware throws instead of quietly running the tail of the chain again.
The ctx those handlers receive is the second half of the router. It is a small facade over one raw message: ctx.text, ctx.command, ctx.args, ctx.sender, ctx.isGroup, and the reply verbs (reply, send, react, replyWithImage, and the rest). None of them touch the socket directly; they call back into the client, which is what keeps the low-level escape hatch honest. If you ever need the raw Baileys node, it is one property away at ctx.message.raw, but you almost never reach for it.
Turning a firehose into a for-await loop#
Callbacks are the right default, but they are the wrong shape for a linear flow: a signup wizard, a step-by-step form, anything where message two depends on message one. For those, whatsweb lets you iterate messages with a plain for await loop instead of registering handlers:
await wa.start();
const me = await wa.waitUntilReady();
for await (const ctx of wa.messages()) {
if (ctx.command === 'ping') await ctx.reply('pong');
}The interesting problem is what happens when messages arrive faster than the loop body handles them. An event emitter does not wait for you; if three messages land while you are awaiting a slow reply, a naive bridge drops two of them. So messages() is backed by a small buffer. When a message arrives and someone is already waiting on next(), it resolves that promise straight away. When no one is waiting, it pushes onto the buffer, and the next next() drains it before it ever waits. Producer and consumer run at their own speeds and nothing is lost between iterations.
const onEvent = (arg: T): void => {
const resolve = waiting.shift();
if (resolve) resolve({ value: arg, done: false }); // someone is waiting
else buffer.push(arg); // no one is; hold it
};The same primitive powers wa.stream('reaction') for any event, wa.next('ready') for a one-shot await, and an optional AbortSignal that ends the loop cleanly. Callbacks and the loop are the same messages seen two ways, so you pick per bot, or per handler, without choosing a side up front.
Media from anywhere, detected not declared#
Sending a file is where a lot of the fiddly real-world code lives, because the file can come from anywhere: a local path, an http URL, a private S3 object, a Buffer you built in memory, a Node stream. whatsweb resolves all of those to bytes behind one MediaSource type, so sendImage does not care where the image came from.
The part I enjoyed writing is sendFromLink, which takes a URL and works out for itself whether it is an image, a video, a voice note, or a document. It tries the cheap answer first, the file extension, and only falls back to a network round trip when the extension tells it nothing:
export async function detectMediaKind(link: string): Promise<MediaKind> {
const byExtension = kindFromExtension(link);
if (byExtension) return byExtension;
if (/^https?:\/\//i.test(link)) {
const res = await fetch(link, { method: 'HEAD' });
const type = res.headers.get('content-type')?.split(';')[0]?.trim();
const byMime = type ? kindFromMime(type) : undefined;
if (byMime) return byMime;
}
return 'document';
}The fallback matters more than it looks. A presigned S3 URL often has no useful extension and sometimes rejects a HEAD request outright. Rather than throw, the detector lands on document, which WhatsApp accepts for anything. The worst case is a PDF that arrives as a file instead of an inline preview, never a crash. One caveat the SDK is loud about: the URL is fetched by your Node process, not by WhatsApp, so it has to be reachable from your server, and for a private bucket you pass a presigned URL or stream the object yourself.
Conversations without a database#
The last piece is the one that turns a message router into something that feels like it remembers you. ctx.conversation is a per-user handle with three things bolted on: persistent state, recent history, and ask, which sends a question and waits for that person's next reply. Together they make a wizard read top to bottom:
wa.command('signup', async (ctx) => {
const convo = ctx.conversation;
const name = await convo.askText('what is your name?');
const email = await convo.askText('and your email?', { timeoutMs: 60_000 });
await convo.state.patch({ name, email });
await convo.text(`thanks ${name}, you are all set`);
});ask is waitForMessage with a filter, and the filter is what makes it correct in a group. It resolves only for a reply in the same chat from the same sender, so two people running signup in the same group do not answer each other's questions. State is scoped per chat and goes through a pluggable store, in-memory by default; you implement one interface to back it with Redis or Postgres and nothing in your handlers changes. History is a bounded, in-memory ring per chat, off if you do not want it.
I made one deliberate omission here. whatsweb does not wrap a language model, and it never will. It just turns the conversation into a neutral transcript with toMessages(), which produces the exact { role, content } array agent frameworks already expect, your messages as assistant turns and the other party's as user:
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
wa.on('message', async (ctx) => {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
system: 'you are a helpful WhatsApp assistant. keep replies short.',
messages: ctx.conversation.toMessages({ limit: 20 }),
});
await ctx.replyWithTyping(text);
});The AI SDK streams the answer, whatsweb delivers it, and the model stays entirely your choice. This is the same split the last post ended on: transport is the platform-shaped, hard half, and the agent is the portable half. whatsweb is transport, and it works to stay out of the agent's way.
Staying connected, and not getting banned#
Two smaller things earn their place because a bot runs unattended. The socket will drop, so reconnection is on by default with exponential backoff and a little jitter, and it stops on a real logout rather than fighting a locked-out session forever. And WhatsApp will ban a number that sends too fast, so an optional queue serializes outgoing messages and holds a minimum gap between them.
createClient({
autoReconnect: true, // default
maxReconnectAttempts: 10, // default: Infinity
reconnectDelayMs: 1000, // base backoff, capped at 30s
rateLimitMs: 1000, // at least 1s between outgoing messages
});The queue is off by default, because most bots do not need it and pacing every send makes a chatty bot feel slow. It is there for the moment you notice you are sending faster than a human ever could, which is exactly the moment WhatsApp starts to notice too.
Where it's the wrong choice#
Skipping the browser fixes the memory and the boot time. It does not fix the thing that made the browser necessary in the first place.
- It is still the unofficial door. Baileys speaks the protocol a linked device uses, which is cleaner than a scraped page but no more blessed. WhatsApp does not offer a public API for unofficial clients, automating a personal account can violate their terms, and a number can be banned. whatsweb is right for personal automation, a home project, a bot for a group of friends. For a business sending at volume, the honest answer is still the official Cloud API, with all the ceremony the last post laid out.
- It rides an unofficial protocol implementation. Baileys tracks a moving target by reverse engineering, and the version whatsweb builds on is a release candidate. When WhatsApp changes the protocol, things can break under you in a way a documented API never would. A pinned browser has the same exposure to web-client changes, so this is a lateral move on reliability, not a win.
- No browser means no rendering. Anything that only exists as rendered WhatsApp Web UI, rather than as a protocol message, is not reachable this way. For a bot that reads and writes messages, media, groups, and presence, that gap is empty. For anything exotic, check that the protocol exposes it before you assume the SDK can.
- Rate limiting and good behaviour are still on you. The queue helps, but the SDK cannot know your account's limits. Send like a person, not like a script, or the ban question answers itself.
The browser was never the point#
Put the two doors side by side and the browser stops looking like a technical choice. whatsapp-web.js runs Chromium not because a bot needs a browser, but because the browser was the one client WhatsApp left open to a normal account, and driving it was easier than implementing the protocol. Baileys did the hard thing and implemented the protocol, which is why the browser can go. But implementing the protocol only gets you a socket and a firehose, and a socket is not a bot any more than a running Chromium was.
So whatsweb is not really "WhatsApp without a browser". It is the same bet the browser made, paid differently: WhatsApp's "no" still has to live somewhere in your code. The browser carried it as a page you drove like a person. whatsweb carries it as a WebSocket you speak like a client, and then spends most of its lines making that socket feel like the friendly thing the browser was pretending to be. You do not get out of paying. You get to pay once, in a library, and keep the memory.
References
- Baileys the TypeScript Multi-Device client whatsweb builds on
- whatsweb on npm @whatsweb/core, plus the docs at pablofdezr.github.io/whatsweb
- whatsapp-web.js the browser-driven approach this one is a reaction to
- Vercel AI SDK the agent half toMessages() plugs into
Related articles
How I'm building Protomarc, a system that marks images, video, audio, and PDF so a file can prove who owns it and, if it leaks, point back to whoever it was given to. A high-level look at the problem and the hard parts.
A price on a page is a fact someone paid to produce, sitting in public for anything to copy. I'm building my own anti-bot seal to change what that copy costs, and this is how it works.