Back to Blog
App DevelopmentPublished on August 14, 2026

Deconstructing the AT Protocol Firehose: Engineering High-Throughput Indexers for Federated Networks

Building scalable consumer services for the AT Protocol requires mastering Merkle Search Trees, DAG-CBOR parsing, and zero-loss websocket pipelines. This deep architectural guide walks through engineering a resilient AppView indexer capable of ingesting tens of thousands of federated events per second.

The Architecture of Open Federation: Beyond the Monolithic Feed

The fundamental design pivot of the Authenticated Transfer (AT) Protocol—the underlying engine powering decentralized networks like Bluesky—is the clean architectural separation between identity, data custody, event relaying, and consumption views. Unlike traditional monolithic platforms where a single database cluster governs auth, storage, and indexing, the AT Protocol distributes these responsibilities across distinct network roles:

  1. Personal Data Servers (PDS): Host cryptographically signed user repositories structured as Content Addressable Archives (CAR).
  2. Relays (formerly Big Graph Services or BGS): Crawl, aggregate, and deduplicate repository commits from thousands of PDS nodes, broadcasting a unified real-time event stream.
  3. AppViews (Application Views): Consume the aggregated Relay stream, decode records, resolve identity, compute search indexes, and serve customized application APIs.

For systems engineers and backend architects, building an AppView or a specialized analytics engine poses a non-trivial stream-processing challenge. The AT Protocol Relay 'firehose' (com.atproto.sync.subscribeRepos) emits an unrelenting deluge of DAG-CBOR encoded operations. Ingesting this stream without succumbing to backpressure, dropped websockets, or desynchronized state requires mechanical sympathy with binary serialization and distributed consensus primitives.


Anatomy of an ATProto Commit: MSTs and DAG-CBOR

To build an efficient ingestion pipeline, one must understand what actually travels over the wire. The firehose does not emit human-readable JSON payloads; it streams binary WebSocket frames containing Content-Addressable Archives (CAR files) that encapsulate Merkle Search Tree (MST) mutations.

The Merkle Search Tree (MST)

An MST is a deterministic, balanced search tree that functions as a cryptographically verifiable key-value store. Keys are collection paths (e.g., app.bsky.feed.post/3k6b2...), and values are Content Identifiers (CIDs) pointing to DAG-CBOR blocks.

When a user creates, updates, or deletes a record, their PDS generates a commit that includes:

  • A pointer to the previous commit CID (forming a hash chain).
  • The root CID of the updated MST.
  • A signed commit header proving ownership via the user's Decentralized Identifier (DID) signing key.
  • A collection of CAR-encoded blocks containing the new or mutated data blocks.
+-------------------------------------------------------------+
| WebSocket Frame: com.atproto.sync.subscribeRepos#commit     |
+-------------------------------------------------------------+
| Header: { "op": 1, "t": "#commit" }                         |
| Payload (CBOR):                                             |
|   - seq: 894120492        (Monotonically increasing cursor) |
|   - repo: did:plc:z72i... (Author DID)                      |
|   - commit: CID(...)      (Root commit hash)                |
|   - prev: CID(...)        (Parent commit hash)              |
|   - ops: [                (Array of CRUD path operations)   |
|       { action: "create", path: "app.bsky.feed.post/3..." } |
|     ]                                                       |
|   - blocks: <CAR File Bytes containing DAG-CBOR nodes>      |
+-------------------------------------------------------------+

Deserializing this directly in the hot path of an unbuffered WebSocket listener is a recipe for catastrophic consumer lag. If your worker spends 5ms decoding a CAR slice and executing an SQL INSERT, a burst of 1,000 commits per second will overwhelm your TCP receive window within seconds, triggering connection termination by the Relay.


Architecting a Zero-Loss Ingestion Pipeline

A production-grade indexer must decouple Ingestion/Framing, Binary Decoding, and Database Projection into distinct, lock-free stages connected by bounded ring buffers or ring-channel architectures.

[ Relay WebSocket ] 
        │ (Raw Binary Stream)
        ▼
[ Socket Ingestor Thread ] 
        │ (Zero-Copy Frame Extraction)
        ▼
[ Bounded Ring Buffer (LMAX/Crossbeam) ]
        │
   ┌────┴────────────────────────┐
   ▼                             ▼
[ Worker: DAG-CBOR Unpack ]   [ Worker: DAG-CBOR Unpack ]
   │                             │
   └────┬────────────────────────┘
        ▼
[ Batch State Projector (Postgres/Scann/ClickHouse) ]

Stage 1: The Socket Ingestor (Low-Latency Capture)

The ingestor's sole responsibility is maintaining the WebSocket session, acknowledging frames, extracting the raw byte payload alongside the sequence ID (seq), and pushing it onto a lock-free bounded queue. In languages like Rust or Go, use non-allocating socket readers to minimize garbage collection pauses.

// Minimal Go ingestion loop minimizing heap allocations
type RawEvent struct {
    Seq  int64
    Data []byte
}

func ListenFirehose(ctx context.Context, relayURL string, out chan<- RawEvent) error {
    conn, _, err := websocket.DefaultDialer.DialContext(ctx, relayURL, nil)
    if err != nil {
        return fmt.Errorf("connection failed: %w", err)
    }
    defer conn.Close()

    for {
        messageType, reader, err := conn.NextReader()
        if err != nil {
            return err
        }
        if messageType != websocket.BinaryMessage {
            continue
        }

        // Stream directly into a pre-allocated buffer
        buf := bytePool.Get().(*bytes.Buffer)
        buf.Reset()
        _, err = buf.ReadFrom(reader)
        if err != nil {
            bytePool.Put(buf)
            return err
        }

        // Dispatch to worker queue
        out <- RawEvent{Data: buf.Bytes()}
    }
}

Stage 2: Parallelized Block Decoding & Parsing

Workers dequeue raw frames and parse the CBOR header. If the operation targets collections relevant to your application (e.g., app.bsky.feed.post, app.bsky.graph.follow, or custom Lexicons), the worker slices the embedded CAR archive.

Using a zero-copy CAR parser, look up the specific block CIDs identified in the ops array without fully materializing the rest of the MST nodes. This reduces CPU overhead by up to 75% compared to naive full-tree deserialization.


DID Resolution and Caching Strategies

Every event references a repo by its DID (such as did:plc:z72i7hdg6nlq73sl4... or did:web:example.com). The firehose does not bundle the user's handle, display name, or public keys in the commit payload; it only supplies the immutable DID.

To translate DIDs into human-readable handles and verify cryptographic signatures, your indexer must interface with identity directories:

  • did:plc: Resolved via the PLC directory (https://plc.directory/{did}).
  • did:web: Resolved via standard HTTPS .well-known/did.json lookups.

Designing the Identity Cache Tier

Resolving DIDs synchronously over HTTP during feed ingestion will instantly exhaust your network sockets. Implement a two-tier caching architecture:

  1. In-Memory Cache (e.g., Fast LRU / Ristretto): Store the mapping did -> { handle, signing_key, pds_endpoint } with a TTL of 1 hour.
  2. Stale-While-Revalidate Strategy: When an unknown DID appears, return a placeholder handle immediately, publish the DID to an asynchronous resolution worker pool, and update your projection asynchronously.
  3. Handle Invalidation via Identity Events: Relays emit #identity events on the firehose whenever a user updates their handle or key rotation occurs. Intercepting #identity and #account records enables zero-latency cache invalidation without periodic polling.

Handling High-Throughput Re-indexing and Cursors

Network interruptions are inevitable. When your consumer disconnects, you must resume streaming precisely where you left off without processing duplicate events or leaving gaps in historical state.

Cursor Persistence & Replay Mechanics

The ATProto relay API accepts a ?cursor= query parameter:

GET wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos?cursor=894120492

To ensure transactional consistency between state changes and cursor progress:

  1. Persist the latest processed seq within the same atomic database transaction as the record mutations:
BEGIN;
-- Apply batch mutations
INSERT INTO posts (cid, author_did, content, created_at) 
VALUES ('bafyre...', 'did:plc:...', 'Hello ATProto', NOW())
ON CONFLICT (cid) DO NOTHING;

-- Atomically update the consumer cursor
INSERT INTO consumer_state (consumer_id, last_seq, updated_at)
VALUES ('indexer_node_1', 894120492, NOW())
ON CONFLICT (consumer_id) 
DO UPDATE SET last_seq = EXCLUDED.last_seq, updated_at = NOW();
COMMIT;
  1. Backpressure-Driven Replay Catch-up: When reconnecting after significant downtime, the event delta between your database cursor and the live Relay head can span millions of events. During catch-up mode, disable unneeded index triggers and batch your database insertions into chunks of 1,000 to 5,000 records to saturate IOPS without exhausting connection pools.

Summary: Blueprint for a Resilient ATProto Consumer

Architecting custom services on the AT Protocol requires shifting away from request-reply paradigms toward streaming dataflow systems. By isolating the WebSocket consumer, optimizing DAG-CBOR binary decoding, leveraging an event-driven DID cache, and strictly binding sequence cursors to transactional writes, you can build indexing infrastructure that effortlessly scales alongside the next generation of federated networks.

#Bluesky#AT Protocol#Distributed Systems#Event Streaming#High Performance