# Reverse Engineering Meta’s Messaging Protocol

> A 2021 Facebook Messenger transport investigation that began with a backwards MQTT keepalive and became part of the production Messenger channel in Texts.

Sid Jain · Published 2026-08-02

Canonical post: https://f0rr0.dev/writing/facebook-messenger-protocol-stack

---

One of the most useful packets in this project contained no payload:

```text
C0 00
```

Those two bytes are an MQTT `PINGREQ`. According to the [MQTT 3.1.1 specification](https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html), the client sends one when it has nothing else to say, and the broker answers with `PINGRESP`. This one had travelled in the opposite direction. Facebook's broker had sent it to me.

This happened in 2021, while I was building a client that could load my own Facebook Messenger conversations and follow new messages without the official mobile app. What began as protocol research became the foundation of the production Messenger channel I shipped for [Texts](https://texts.com/), its all-in-one desktop messaging client.

## A small packet, backwards

My first hypothesis was that MQTT was a misleading surface and the parser underneath it needed replacing. It was also the expensive hypothesis, which should have made me suspicious sooner. Packet framing, QoS acknowledgements, subscriptions, reconnection and ordinary publications all matched MQTT 3.1.1. Only this keepalive did not.

I tried answering it: accept an incoming `PINGREQ`, answer with `PINGRESP`, and leave the rest of the state machine alone. The connection stayed up.

```ts
// Messenger's 2021 dialect, pared down to the behavioural exception.
client.on(PacketType.PingReq, () => {
  transport.write(writePacket(PacketType.PingResp));
});
```

That looks laughably small after an evening spent doubting the transport. It also set the method for everything that followed: start with the public standard, keep what matches, and put each private variation behind a narrow seam.

<figure id="paper-illustration">
  <Image
    src="https://raw.githubusercontent.com/f0rr0/f0rr0.dev/next/src/content/blog/facebook-messenger-protocol-stack/backwards-packet.webp"
    alt="A coral packet marked C0 00 travels from a server toward a client computer, above an engineer's notebook and network cable."
    sizes="(min-width: 1024px) 672px, (min-width: 768px) 704px, (min-width: 640px) calc(100vw - 64px), calc(100vw - 32px)"
  />
  <figcaption>The useful clue was a keepalive travelling the wrong way.</figcaption>
</figure>

I worked with my own accounts and test devices, using Ghidra to follow native code and Burp Suite and Frida to compare what the app did with what went over the wire. Each small experiment gave me another piece of the conversation.

## One identity across two transports

Keeping a socket alive did not make it a Messenger session. The HTTPS and realtime sides had to agree on the same account, app, device, locale and authenticated session. When I let each side assemble that identity independently, I got a particularly annoying failure mode: login succeeded, the realtime connection looked healthy, and nothing useful arrived.

The missing piece was ordering. An HTTPS request established an initial snapshot and sequence cursor; the realtime client then used the same session state and cursor to subscribe to later deltas. Meta had publicly described this [snapshot-and-delta architecture](https://engineering.fb.com/2014/10/09/production-engineering/building-mobile-first-infrastructure-for-messenger/) years earlier: an initial message snapshot followed by updates over MQTT, with Thrift replacing JSON to reduce wire size. That description didn't provide the private operations or schemas, but it gave the traffic a sensible shape.

I moved the authenticated device and session context into one shared model:

```mermaid
flowchart LR
  accTitle: A Messenger session across two transports
  accDescr: HTTPS login creates shared session state. An initial snapshot supplies the sequence cursor that the realtime connection uses to receive subsequent deltas.
  Login["HTTPS login"] --> State["Shared device + session state"]
  State --> Snapshot["Snapshot + sequence cursor"]
  State --> Realtime["Realtime connection"]
  Snapshot --> Realtime
  Realtime --> Deltas["Ordered deltas"]
```

Authentication made the snapshot possible; the snapshot made the stream meaningful. Once both sides shared that starting point, a healthy connection could do something useful.

## MQTT outside, MQTToT inside

The realtime handshake made the layering visible. Facebook's dialect, usually called MQTToT, reused MQTT's fixed header and remaining-length framing but did not send an ordinary MQTT `CONNECT`. Inside that envelope sat an MQTToT header and a compressed Thrift connection object.

The connection writer, with the payload details left out, looked roughly like this:

```ts
const PROTOCOL_LEVEL = 3;
const CONNECT_FLAGS = 0xc2;

stream
  .writeString("MQTToT")
  .writeByte(PROTOCOL_LEVEL)
  .writeByte(CONNECT_FLAGS)
  .writeWord(keepAlive)
  .write(compressedThriftPayload);
```

The compressed payload carried the session and device context. I replaced the connection writer and acknowledgement reader, and kept the MQTT client responsible for buffering, QoS, publications, keepalives and reconnects. Each new discovery needed a small place to go.

A command therefore crossed a stack of ordinary-looking layers:

```text
typed object
  → Thrift Compact
  → DEFLATE
  → MQTToT inside MQTT framing
  → TLS
```

Keeping those layers separate saved a lot of theatrical debugging. A framing failure belonged near MQTT. A buffer that would not inflate belonged to compression. A valid Thrift structure with the wrong field type belonged to the schema. A perfectly decoded delta with a stale cursor belonged higher up. Without those boundaries, all four looked like “messages don't work”.

## Giving numbered fields names, slowly

Thrift Compact supplied field numbers, wire types and container boundaries, but not domain names. The [Compact Protocol specification](https://github.com/apache/thrift/blob/master/doc/specs/thrift-compact-protocol.md) could tell me that a field was an `i64` or a list of structs. It could not tell me whether that `i64` represented a user, a thread, or a timestamp.

I first wrote a schema-free structural reader. It printed numbered fields and nesting without pretending to know what they meant. Then I made controlled changes in the app—send a message, add a reaction, mark a thread read—and compared captures. A field earned a name only when its value changed consistently with the visible action. Others stayed numbered until I had a reason to name them. The structural reader let me keep working while the vocabulary grew.

Repeated observations became runtime schemas in TypeScript. Each schema kept the wire number, expected type, optionality and TypeScript representation together. Unknown fields were skipped recursively, while a known field changing wire type failed at that field. Sixty-four-bit identifiers remained `bigint`; passing them through JavaScript's `number` would make two different accounts look the same eventually, which is a rather dramatic typing bug.

I published the reusable, non-Messenger-specific parts as [`@f0rr0/thrift-compact-protocol`](https://github.com/f0rr0/thrift-compact-protocol). The extensible MQTT 3.1.1 state machine is also visible in my public [`mqtts` fork](https://github.com/f0rr0/mqtts).

<div className="github-embed-grid grid gap-4 [margin:2rem_0] sm:[grid-template-columns:repeat(2,_minmax(0,_1fr))]">

https://github.com/f0rr0/thrift-compact-protocol

https://github.com/f0rr0/mqtts

</div>

## From packet archaeology to a product

Decoding one event is a demo. A messaging channel has to keep doing it after sleep, reconnect, cursor advancement, an attachment upload, and a remote schema change. The research client grew into the Messenger integration in Texts: message synchronisation and encrypted payload handling, sending and receiving, threads and groups, photos, videos and files, reactions, read receipts, typing indicators, and presence.

The little backwards ping had sent me into the project expecting to replace a transport. By the end, I had kept most of it and learnt to read the conversation inside it. `C0 00` was still only two bytes. It had been a very useful place to start.
