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 inwacore/binary/, a platform-agnostic crate:
Node Structure
Node Definition
A node represents a protocol message or message component: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: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:
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:
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
Theserver 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.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 ofJidwhereuserisNodeStr<'a>(borrowed or inline) andserveris theServerenum (alreadyCopy). Used for zero-copy decoded JIDs inNodeRefattributesDeviceKey<'a>— a lightweight key containing(&'a str, &'a str, u16)for user/server/device, used forHashSetlookups without cloning
wacore/binary/src/node.rs:10-112, wacore/binary/src/jid.rs
JidExt trait
TheJidExt 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
TheNodeBuilder 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
Thejid_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
Uselet mut builder with reassignment for conditional attributes:
Token Dictionary
The protocol uses a token dictionary to compress common strings into single bytes.Token Types
wacore/binary/src/token.rs
Unified token lookup
Both single-byte and double-byte tokens are resolved by a single compile-time hashifytiny_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:
TokenKind enum distinguishes single-byte from double-byte tokens:
- Protocol tags (“message”, “iq”, “presence”)
- Common attributes (“id”, “type”, “to”, “from”)
- Frequent values (“text”, “chat”, “available”)
wacore/binary/src/token.rs
Encoding Process
Marshal Functions
wacore/binary/src/marshal.rs:31-76
Encoding Strategy
The encoder uses multiple strategies based on data characteristics: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:wacore/binary/src/encoder.rs:769-777
Hex Packing
Uppercase hex strings (0-9, A-F) are packed into 4 bits per character:wacore/binary/src/encoder.rs:780-787
SIMD Optimization
The encoder uses SIMD instructions for fast packing of long strings:wacore/binary/src/encoder.rs:809-824
JID Encoding
JIDs have special compact encodings:JID_PAIR (Standard JID)
wacore/binary/src/encoder.rs:706-715
AD_JID (Device-Specific JID)
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:
Location:
wacore/binary/src/encoder.rs:699-705, 362-369
List Encoding
Lists (including node structures) have length-prefixed encoding:wacore/binary/src/encoder.rs:865-876
Node encoding format
A complete node is encoded as:list_len = 1 (tag) + (num_attrs * 2) + (content ? 1 : 0)
wacore/binary/src/encoder.rs:879-889
Decoding Process
Decoder Structure
wacore/binary/src/decoder.rs
Zero-copy decoding
The decoder usesNodeRef<'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.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_ref → read_content → read_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.
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.
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 viaNodeBuilder.NodeRef<'a>(borrowed) — used for reading received stanzas. Borrows from the network buffer.OwnedNodeRef— wraps aNodeRefwith its backing buffer viayoke, enabling safe zero-copy sharing across handler tasks asArc<OwnedNodeRef>.
Zero-copy serialization
The entireNodeRef 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.
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.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:
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 viaJid::from_str. Parse failures are captured in the error list and surfaced when you callfinish(), rather than being silently discarded.
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:wacore/binary/src/decoder.rs:400-450
Performance Optimizations
Token interning with Cow
When converting decodedNodeRef values to owned Node values, the intern_cow function maps known protocol strings to their static references using the unified hashify lookup:
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"Attrskeys — 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.wacore/binary/src/node.rs:108-123
Two-Pass Encoding
For large or variable-size payloads, exact size calculation prevents buffer growth:wacore/binary/src/marshal.rs:67-76
String hint cache
Repeated strings (like JIDs) are analyzed once and cached. Strings longer thanPACKED_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:
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. TheCow<'static, str> tag on owned Node works transparently since Cow implements Deref<Target = str>:
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% instructionsmarshal_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)
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:
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:
Wire format examples
Simple Message
Message with Body
Debugging Tools
Inspecting encoded data
Useevcxr REPL for interactive exploration:
Error Handling
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
Related Components
- Signal Protocol - How messages are encrypted before marshaling
- WebSocket Handling - How binary data is framed and transmitted
- State Management - Protocol state stored in Device
References
- Source:
wacore/binary/src/ - Token dictionary:
wacore/binary/src/token.rs - Node builder:
wacore/binary/src/builder.rs