---
title: Building a Better WhatsApp SDK
date: '2026-07-22'
description: 'whatsapp-web.js runs a whole headless Chromium to act like a phone. The other door skips the browser and speaks WhatsApp''s native protocol over a plain WebSocket, then hands you a raw socket and a firehose of events. whatsweb is the thin layer I built to make that socket feel like a bot.'
author: Pablo Fernandez
tags:
  - WhatsApp
  - Baileys
  - TypeScript
  - Bots
  - SDK
source: 'https://www.pablofr.com/blog/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](https://wwebjs.dev/) 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](https://github.com/WhiskeySockets/Baileys) protocol is a real thing: your phone and your linked laptops each hold their own [Signal](https://signal.org/docs/) keys and each open their own encrypted [WebSocket](https://github.com/WhiskeySockets/Baileys) to WhatsApp's servers. A linked laptop is not scraping a web page; it is a first-class client speaking a binary protocol. [Baileys](https://github.com/WhiskeySockets/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.

```
   whatsapp-web.js                      Baileys
   ─────────────────                    ────────────
   a headless browser                   a WebSocket
   renders WhatsApp Web                 speaks the protocol
   hands you a tidy message             hands you a raw node
   ~400 MB, slow boot                   ~tens of MB, fast boot
```

*The trade for dropping the browser: you keep the RAM, but the message-shaping work moves onto your side.*

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:

```typescript:bot.ts {6,7}
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.

```
   ┌───────────────────────────────────────────────┐
   │  your bot                                     │
   │  wa.command('ping'), wa.hears(/hi/i)          │
   ├───────────────────────────────────────────────┤
   │  whatsweb                                     │
   │  router, Context, media, conversations        │
   ├───────────────────────────────────────────────┤
   │  Baileys                                      │
   │  the Multi-Device socket, raw message nodes   │
   ├───────────────────────────────────────────────┤
   │  a WebSocket to WhatsApp, no browser          │
   └───────────────────────────────────────────────┘
```

*Where whatsweb sits: it turns a raw Multi-Device socket into a bot API, without hiding the socket underneath.*

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](https://telegraf.js.org/) and [Koa](https://koajs.com/) 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.

```typescript:router.ts /next/
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`:

```typescript:command.ts {4,5}
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.

```
   a message
       │
       ▼
   use(logRequest) ──▶ use(ignoreGroups) ──▶ command('ping')
                                                   │
                              matched? run handler, stop here
                              no match? call next(), keep walking
                                                   │
                                                   ▼
                                             hears(/hello/i)
```

*Every message walks one middleware chain in order; a handler that skips next() ends the walk.*

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:

```typescript:async-bot.ts
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.

```typescript:eventStream.ts {2,3}
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:

```typescript:detect.ts /document/
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.

```
   a link
     │
     ├─ known file extension?  ──yes──▶ image / video / audio / document
     │        │ no
     ├─ http(s) HEAD Content-Type?  ──yes──▶ image / video / audio
     │        │ no, or the request was rejected
     └──────────────────────────────────────▶ document (always accepted)
```

*How sendFromLink picks a media kind: cheap guess first, one network probe next, a safe default last.*

## 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:

```typescript:signup.ts
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.

```
   convo.askText('what is your email?')
              │  sends the question, then waits
              ▼
   waitForMessage(chat === this chat, sender === this sender)
              │  other people's messages are ignored, buffered elsewhere
              ▼
   'ada@example.com'  ──▶  resolves the promise, the wizard continues
```

*ask() is a question plus a filtered wait: it resolves only for the same chat and the same sender.*

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`:

```typescript:agent.ts {6}
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](https://ai-sdk.dev/) 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.

```typescript:resilience.ts {5}
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](/blog/distance-from-a-message-to-your-code) 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](https://github.com/WhiskeySockets/Baileys) — the TypeScript Multi-Device client whatsweb builds on
- [whatsweb on npm](https://www.npmjs.com/package/@whatsweb/core) — @whatsweb/core, plus the docs at pablofdezr.github.io/whatsweb
- [whatsapp-web.js](https://wwebjs.dev/) — the browser-driven approach this one is a reaction to
- [Vercel AI SDK](https://ai-sdk.dev/) — the agent half toMessages() plugs into
