Skip to main content

Overview

WhatsApp uses a custom binary protocol for all communication between clients and servers. This format is significantly more compact than JSON or XML and optimized for mobile network conditions. The protocol encodes messages as nodes - hierarchical structures with tags, attributes, and content. All nodes are serialized to binary format before encryption and transmission.

Architecture

The binary protocol implementation is in wacore/binary/, a platform-agnostic crate:

Node Structure

Node Definition

A node represents a protocol message or message component:
The tag field uses Cow<'static, str> so that known protocol tags (like "message", "iq", "receipt") are borrowed as zero-allocation static references from the token dictionary, while unknown tags fall back to an owned String. Location: wacore/binary/src/node.rs:459

Attributes

Attributes are stored as key-value pairs with specialized value types:
Like Node.tag, attribute keys use Cow<'static, str> so that common protocol attribute names (like "id", "type", "to", "from") reference static memory from the token dictionary rather than allocating on the heap. AttrsVec is a SmallVec with inline capacity 2. Nodes carrying ≤2 attributes — the common per-recipient fanout shapes (to with 1 attr, enc with 2) — keep their attributes on the stack alongside the node with no heap allocation. Nodes with ≥3 attributes spill to the heap as before. See Inline attribute storage for performance numbers.

NodeValue API

NodeValue provides exactly two methods for accessing the underlying value, regardless of variant:
This simplified API means you never need to match on the variant directly — use as_str() when you need the value as text, and to_jid() when you need a structured JID:
NodeValue also implements PartialEq<str> for zero-allocation comparisons — the Jid variant compares byte-by-byte against the formatted string without allocating. Location: wacore/binary/src/node.rs:39-58 Why Jid as a separate type? JIDs (Jabber IDs) like 15551234567@s.whatsapp.net appear frequently in the protocol. Storing them as structured data avoids repeated parsing/formatting overhead:
The user field uses CompactString (re-exported from compact_str) instead of String. CompactString stores short strings inline (up to 24 bytes on 64-bit platforms) without heap allocation, which benefits typical phone numbers and user identifiers. The library re-exports it as wacore_binary::CompactString and whatsapp_rust::CompactString for convenience. CompactString implements From<&str>, From<String>, and Deref<Target = str>, so it works as a drop-in replacement in most contexts — but code that relied on Jid.user being a String (e.g., passing it to functions expecting &String or calling String-specific methods) may need updating.

Server enum

The server field is a Server enum (#[repr(u8)]) that maps to the wire protocol’s AD_JID domain type. This replaces the previous Cow<'static, str> string representation, eliminating all heap allocation for server identifiers and enabling match-based dispatch instead of string comparisons:
Server implements Display (returns the wire string like "s.whatsapp.net"), as_str() for zero-cost string access, TryFrom<&str> for parsing, Serialize/Deserialize (as the wire string), and PartialEq<str> / PartialEq<&str> for backward-compatible string comparisons:
If you previously compared jid.server to string constants like "s.whatsapp.net", the PartialEq<str> impl on Server preserves backward compatibility. However, match on the enum variant is preferred for exhaustiveness checking and performance.
String constants are still available for backward compatibility and use in non-JID contexts:

Typed constructors

Convenience constructors avoid specifying the server directly:

Borrowing types

For zero-allocation lookups and comparisons, the protocol also provides:
  • JidRef<'a> — a borrowing version of Jid where user is NodeStr<'a> (borrowed or inline) and server is the Server enum (already Copy). Used for zero-copy decoded JIDs in NodeRef attributes
  • DeviceKey<'a> — a lightweight key containing (&'a str, &'a str, u16) for user/server/device, used for HashSet lookups without cloning
Location: wacore/binary/src/node.rs:10-112, wacore/binary/src/jid.rs

JidExt trait

The JidExt trait provides type-checking methods on JIDs. It is implemented for Jid, JidRef, and other borrowing types so you can inspect a JID’s server type without string comparisons:
The trait also exposes basic accessor methods (user() -> &str, server() -> Server, device() -> u16, integrator() -> u16) that work uniformly across owned and borrowed JID types. Additional helper methods is_pn() and is_lid() are available directly on Jid for the most common server checks. Location: wacore/binary/src/jid.rs:304-363

NodeBuilder API

The NodeBuilder provides a fluent chaining API for constructing nodes. All setter methods consume and return Self:

Available methods

The new and attr methods accept &'static str for tags and keys, which creates Cow::Borrowed values on the owned Node with zero heap allocation. For rare cases where the tag is computed at runtime, use new_dynamic. Location: wacore/binary/src/builder.rs

jid_attr vs attr

The jid_attr method stores JIDs as NodeValue::Jid(jid) directly in the attribute map, avoiding the allocation cost of jid.to_string(). Use jid_attr for JID-valued attributes like to, from, and participant on hot paths:

Conditional chaining

Use let mut builder with reassignment for conditional attributes:

Token Dictionary

The protocol uses a token dictionary to compress common strings into single bytes.

Token Types

Location: wacore/binary/src/token.rs

Unified token lookup

Both single-byte and double-byte tokens are resolved by a single compile-time hashify tiny_map, generated by a build script from tokens.json. The dispatch uses a length-bucketed strategy: match key.len() first, then compare only discriminator bytes—no full-key hash. A single call to index_of_token resolves any known protocol string:
The TokenKind enum distinguishes single-byte from double-byte tokens:
The dictionary includes:
  • Protocol tags (“message”, “iq”, “presence”)
  • Common attributes (“id”, “type”, “to”, “from”)
  • Frequent values (“text”, “chat”, “available”)
Reverse lookups (index → string) use separate arrays:
Location: wacore/binary/src/token.rs

Encoding Process

Marshal Functions

Location: wacore/binary/src/marshal.rs:31-76

Encoding Strategy

The encoder uses multiple strategies based on data characteristics:
Location: wacore/binary/src/encoder.rs:227-237

Packed Encoding

Nibble packing (numeric strings)

Strings containing only digits, dash, and dot are packed into 4 bits per character:
Location: wacore/binary/src/encoder.rs:769-777

Hex Packing

Uppercase hex strings (0-9, A-F) are packed into 4 bits per character:
Location: wacore/binary/src/encoder.rs:780-787

SIMD Optimization

The encoder uses SIMD instructions for fast packing of long strings:
Location: wacore/binary/src/encoder.rs:809-824

JID Encoding

JIDs have special compact encodings:

JID_PAIR (Standard JID)

Location: wacore/binary/src/encoder.rs:706-715

AD_JID (Device-Specific JID)

The domain_type byte is derived from the Server enum variant at encoding time, not from the agent field directly. Since Server is #[repr(u8)], the mapping is a direct cast for known variants:
The domain_type must be derived from the JID’s server field via server_to_domain_type(), not from jid.agent. A previous bug wrote jid.agent (which is 0 for most JIDs) unconditionally, causing LID JIDs to be encoded with domain_type=0 instead of domain_type=1. This made LID group messages silently rejected by the server with error 421.
Location: wacore/binary/src/encoder.rs:699-705, 362-369

List Encoding

Lists (including node structures) have length-prefixed encoding:
Location: wacore/binary/src/encoder.rs:865-876

Node encoding format

A complete node is encoded as:
Where list_len = 1 (tag) + (num_attrs * 2) + (content ? 1 : 0)
Location: wacore/binary/src/encoder.rs:879-889

Decoding Process

Decoder Structure

Location: wacore/binary/src/decoder.rs

Zero-copy decoding

The decoder uses NodeRef<'a> to avoid allocations. String and byte payloads borrow directly from the input buffer. Decoded strings use NodeStr<'a> — a borrowed-or-inline string type that stores short owned values (up to 24 bytes) inline via CompactString, avoiding heap allocation:
NodeStr implements Deref<Target = str>, AsRef<str>, PartialEq<str>, and PartialEq<&str>, so you can use it anywhere a &str is expected. It also provides to_compact_string() for efficient conversion to an owned CompactString.
NodeStr replaces the previous Cow<'a, str> used in NodeRef, AttrsRef, ValueRef, and NodeContentRef. The key difference is that the Owned variant uses CompactString (inline up to 24 bytes) instead of String (always heap-allocated), reducing allocation pressure for the many short protocol strings that can’t be statically interned.
Location: wacore/binary/src/node.rs:10-106, 465-469, 437-441

Node nesting depth cap

read_node_ref decodes LIST-typed content recursively — a child LIST_8/LIST_16 node causes read_node_ref to call itself for each nested child. Since a single LIST node needs only ~4 wire bytes to nest one level (list_size == 2, zero attrs, one child), a hostile or malformed frame could otherwise force tens of thousands of recursion levels from a tiny payload and overflow the native call stack. The decoder rejects this before it happens: recursion depth is tracked through read_node_refread_contentread_content_from_tag, and any node nested past MAX_NODE_DEPTH (128 levels) returns BinaryError::MaxDepthExceeded instead of recursing further. Real WhatsApp stanza trees are well under 20 levels deep, so this only ever rejects pathological input.
Location: wacore/binary/src/decoder.rs

OwnedNodeRef (yoke zero-copy)

OwnedNodeRef is a self-referential type that owns the decompressed network buffer while the inner NodeRef borrows string and byte payloads directly from it. This avoids copying payloads out of the buffer during decoding — only container allocations (attribute Vec, child Vec) occur.
Received stanzas flow through the system as Arc<OwnedNodeRef>, giving handlers cheap shared access to the zero-copy decoded node. The to_owned_node() method is available as an escape hatch when you need a fully owned Node, but it allocates all strings and bytes — defeating the zero-copy benefit. Location: wacore/binary/src/node.rs:594-693

Node vs NodeRef usage pattern

  • Node (owned) — used for building and sending outgoing stanzas. Constructed via NodeBuilder.
  • NodeRef<'a> (borrowed) — used for reading received stanzas. Borrows from the network buffer.
  • OwnedNodeRef — wraps a NodeRef with its backing buffer via yoke, enabling safe zero-copy sharing across handler tasks as Arc<OwnedNodeRef>.

Zero-copy serialization

The entire NodeRef type family implements serde::Serialize (gated behind the serde feature), producing output identical to their owned counterparts. This means you can serialize a NodeRef, OwnedNodeRef, ValueRef, JidRef, or NodeContentRef directly — without converting to an owned Node first — avoiding all intermediate allocations.
The following types implement Serialize:
The Serialize implementations use an AttrsRefWrapper to match the newtype-struct framing that serde’s derive produces for Attrs(Vec<...>). This ensures compatibility with binary formats like bincode and postcard, which distinguish between a bare sequence and a newtype struct wrapper.
This is useful for logging, debugging, protocol inspection, and forwarding stanzas to external systems without paying the cost of to_owned_node(). Location: wacore/binary/src/node.rs:67-71, 397-407, 473-488, 612-632, 875-880; wacore/binary/src/jid.rs:603-615 The borrowed counterpart to NodeValue is ValueRef<'a>, used in the decoder path and in NodeRef attributes:
ValueRef provides three methods: as_str() (returns Cow<'_, str> — zero-copy for String, allocates for Jid), as_jid() (returns Option<&JidRef>, only for Jid variant), and to_jid() (converts either variant to owned Jid, parsing from string if necessary). Location: wacore/binary/src/node.rs:282-313

Attribute parsing

AttrParser and AttrParserRef provide structured attribute extraction from owned Node and borrowed NodeRef values respectively. They accumulate parse errors instead of panicking:
The optional_jid method handles both NodeValue variants:
  • If the attribute is a NodeValue::Jid, it returns the JID directly via clone (zero parse cost).
  • If the attribute is a NodeValue::String, it parses via Jid::from_str. Parse failures are captured in the error list and surfaced when you call finish(), rather than being silently discarded.
This ensures that malformed JID strings in protocol messages are reported as BinaryError::Jid (for AttrParser) or BinaryError::AttrParse (for AttrParserRef) instead of silently returning None. Location: wacore/binary/src/attrs.rs

Unpacking

Reverse of the packing process:
Location: wacore/binary/src/decoder.rs:400-450

Performance Optimizations

Token interning with Cow

When converting decoded NodeRef values to owned Node values, the intern_cow function maps known protocol strings to their static references using the unified hashify lookup:
The unified token map is generated at compile time via hashify::tiny_map! with length-bucketed dispatch. The resulting single lookup is O(1) and hash-free. Since the vast majority of node tags and attribute keys are part of the WhatsApp token dictionary, this eliminates most heap allocations during protocol decoding. This optimization applies to:
  • Node.tag — protocol tags like "message", "iq", "receipt"
  • Attrs keys — attribute names like "id", "type", "to", "from"
Jid.server is now a Server enum (a Copy type, #[repr(u8)]), so it requires no allocation at all — neither heap nor interning. This is an improvement over the previous Cow<'static, str> approach.
Location: wacore/binary/src/node.rs:108-123

Two-Pass Encoding

For large or variable-size payloads, exact size calculation prevents buffer growth:
Location: wacore/binary/src/marshal.rs:67-76

String hint cache

Repeated strings (like JIDs) are analyzed once and cached. Strings longer than PACKED_MAX (127 bytes) are immediately classified as RawBytes without running the full classification logic, since they can never be protocol tokens (max 48 bytes), packed nibble/hex, or JIDs:
The same length check is applied in the uncached write path (write_string_uncached), where strings exceeding PACKED_MAX are emitted directly as raw bytes without classification. This avoids unnecessary work for long strings like message bodies, media URLs, and base64-encoded payloads. Location: wacore/binary/src/encoder.rs:240-287

Capacity estimation

Auto-sizing strategy samples node structure to estimate capacity. The Cow<'static, str> tag on owned Node works transparently since Cow implements Deref<Target = str>:
Location: wacore/binary/src/marshal.rs:167-200

Inline attribute storage

Attrs uses AttrsVec = SmallVec<[(Cow<'static, str>, NodeValue); 2]> instead of a plain Vec. The SmallVec stores up to 2 entries inline inside the struct itself, eliminating the per-node heap allocation for the attribute buffer on the encode hot path. Inline capacity 2 is the measured optimum: the per-recipient fanout nodes (to with 1 attr, enc with 2) stay inline, while stanza roots with 3+ attrs spill once per stanza. A larger inline array (4) grows Node from ~184 bytes to ~296 bytes — moving nodes through children Vecs then costs more than the spared spills save. Impact (iai-callgrind vs. Vec backing):
  • marshal_allocating (typical stanza): −6.6% instructions
  • marshal_many_children_allocating (2048 children): −9.9% instructions, −25% RAM hits
  • Allocation count per DM stanza: −27% (15→11 allocs); per group stanza with 800 participants: −40% (4012→2412 allocs)
The spilled() method on SmallVec reports whether a given node’s attrs overflowed to the heap — useful in allocation tests (see wacore/binary/tests/attrs_inline_alloc.rs). Location: wacore/binary/src/node.rs, wacore/binary/tests/attrs_inline_alloc.rs

Common protocol patterns

IQ (info/query) stanzas

Messages

Receipts

Nack reasons

When a stanza fails terminally (unparseable proto, missing message secret, exceeded retry budget, …) the client emits a <nack reason="…"> so the server stops retransmitting. v0.6 introduced the wacore::protocol::nack::NackReason enum which mirrors WA Web’s full set:
Each variant maps to the integer reason code WA Web sends on the wire. The client picks the variant from the decrypt failure path — ParsingError for malformed binary, InvalidProtobuf for wa::Message decode failures, MaxRetryReached after the PDO recovery state machine gives up, and so on. Consumers building custom transports can reuse the enum to produce wire-compatible nacks.

Manual stanza acknowledgement

The automatic receive pipeline already acks and nacks stanzas as it processes them. Callers that intercept raw nodes themselves — custom transports, replay tooling, mock servers — can respond explicitly instead:
acknowledge_stanza sends a plain <ack/> built from the stanza’s id/from (and, for message stanzas, the local device’s PN — this fails with StanzaResponseError::MissingLocalIdentity if pairing hasn’t completed). It always preserves the stanza’s participant attribute, unlike the automatic pipeline, which omits a participant that merely duplicates the from JID on <receipt> stanzas. reject_stanza sends <ack error="…"> built from a StanzaRejection:
invalid_protobuf is the only constructor that can carry a failure_reason — the typed detail from a wa::Message decode failure; every other rejection reason encodes None. acknowledge_stanza accepts any stanza class; reject_stanza accepts only message, receipt, and notification stanzas, unless rejection.reason() is NackReason::UnrecognizedStanza, in which case any class is accepted — matching the protocol’s own catch-all nack path. Malformed input (missing id/from, or reject_stanza called on an unsupported class) is returned to the caller as a typed StanzaResponseError instead of being silently dropped, unlike the tolerant automatic receive path:
See Decryption retry mechanism for the equivalent manual entry point into retry receipts.

Wire format examples

Simple Message

Message with Body

Debugging Tools

Inspecting encoded data

Use evcxr REPL for interactive exploration:

Error Handling

The Jid variant is emitted by AttrParser::optional_jid when a string attribute fails to parse as a JID. This ensures malformed JIDs in protocol messages are surfaced as typed errors rather than silently ignored. The MaxDepthExceeded variant is emitted by read_node_ref when a decoded frame nests LIST nodes past MAX_NODE_DEPTH (128 levels) — see Node nesting depth cap. Since real stanza trees never approach this depth, callers can treat it the same as any other malformed-frame error (drop the connection / reject the frame). Location: wacore/binary/src/error.rs

References

  • Source: wacore/binary/src/
  • Token dictionary: wacore/binary/src/token.rs
  • Node builder: wacore/binary/src/builder.rs