Network Replay with Jetstream
Most applications need more than the live tail. To build an App, run analysis, or catch up after downtime, you want the records that already exist on the network and every new one as it arrives, with no gap in between.
Jetstream serves both from one place. It keeps a compressed archive of the whole network and replays it through the same JSON shape as the live tail, so the handoff from history to real time is handled entirely by Jetstream, transparent to your application.
Three ways to consume Jetstream
| Transport | Auth | |
|---|---|---|
| Live | WebSocket (subscribeEvents) | none |
| Replay | WebSocket + HTTP (plan + download, then tail) | API key on the HTTP calls |
| Snapshot | HTTP only (download, no live tail) | API key |
Each is a way of consuming Jetstream, using the same archive and the same event shape throughout. This page primarily covers replay: catching up from a point in the past and cutting over to live. Snapshotting is below. Replay is a Jetstream v2 feature.
Quickstart: backfill, then live
The Jetstream SDK handles the whole flow — planning the backfill, downloading the archive in parallel, decoding, exact filtering, and the live cutover — behind one loop. Give it a starting point and read:
- TypeScript
- Go
// npm install @bsky/jetstream
import { Jetstream } from '@bsky/jetstream'
const jetstream = new Jetstream({
service: 'https://jetstream.us-west.bsky.network',
apiKey: process.env.JETSTREAM_API_KEY,
})
for await (const evt of jetstream.replay({
collections: ['app.bsky.feed.post'],
afterSeq: 0, // start from the beginning of the archive
})) {
console.log(evt.seq, evt.did, evt.kind)
// persist evt.seq to resume from here after a restart
}
// go get github.com/bluesky-social/jetstream
package main
import (
"context"
"fmt"
"os"
"github.com/bluesky-social/jetstream"
)
func main() {
client, err := jetstream.Subscribe(
"jetstream.us-west.bsky.network",
jetstream.WithAPIKey(os.Getenv("JETSTREAM_API_KEY")),
jetstream.WithCollections([]string{"app.bsky.feed.post"}),
jetstream.WithAfterSeq(0), // start from the beginning of the archive
)
if err != nil {
panic(err)
}
defer client.Close()
for batch, err := range client.Events(context.Background()) {
if err != nil {
continue // recoverable; iteration ends only when the context does
}
for _, evt := range batch.Events() {
fmt.Println(evt.Seq, evt.DID, evt.Kind)
}
// persist batch.LastCursor() to resume from here after a restart
}
}
Pass the raw API key to WithAPIKey; the client handles authentication.
The stream starts in the past and keeps going: when the archive range is consumed, the client connects the live WebSocket at the tip and events continue arriving through the same loop, with the seam deduplicated for you. Your code never needs to know whether an event is history or real time.
If you omit the starting point, you have a pure live tail; live() in TypeScript,
a bare Subscribe(host) in Go. To resume after a restart, persist the last
sequence number you handled and pass it back as afterSeq (or
WithAfterSeq) — the archive picks up from there no matter how long you were
down.
On Bluesky-hosted instances, the HTTP side of replay requires an API key. See Authentication and metering for how to attach it and what the limits look like.
Folding the stream (eventually consistent)
Replay delivers every matching event at least once, in seq order. Creates, updates, and deletes all arrive in a single stream, just as they appear on the network. The server doesn't hide superseded records for you; instead your handler folds the stream to converge on the current state:
- a
createadds a record, anupdatereplaces it, - a
deleteremoves it, - an account-level deletion (an
accountevent withactive: falseandstatus: "deleted") or asyncdivergence marker removes all of that account's records.
Apply events in order and you arrive at network truth. A record you'll later
delete may show up transiently before its delete arrives. That's expected, and
it's why your writes should be idempotent (keying on each record's at://
URI is usually enough).
Account-level events (account, identity, sync) carry no collection, and
Jetstream deliberately delivers them anyway, even to a collection-filtered
consumer, in replay and live alike. If your data model needs to react to an
account being deleted, handle those events rather than dropping them.
(identity events are informational and don't remove records.)
Authentication and metering
On Bluesky-hosted instances, the replay HTTP endpoints are metered. The behavior is designed so that a well-behaved client can recover from every limit without losing work:
- Use an API key (create one here).
Pass the raw key to the SDK's API key option; the SDK handles the
Authorization: Bearer <key>header. A response with a missing, malformed, or revoked key returns401with a body of{"error":"invalid bearer credential"}. - Usage is metered in bytes, not requests. What counts is the response bytes you download (compressed, as sent on the wire); headers and request bodies don't.
- Being rate limited will return a
429with a body of{"error":"byte limit exceeded"}and aRetry-After: <seconds>header. Wait that long and retry; the quota refills continuously rather than resetting on a boundary. - Running out mid-download closes the stream cleanly. The bytes you already
received are intact. Persist them, wait out the quota, and resume with an
HTTP
Rangerequest from your exact byte offset. Nothing is re-charged for data you don't re-download.
The live WebSocket needs no key and is not metered. To attach your key:
- TypeScript
- Go
const jetstream = new Jetstream({
service: 'https://jetstream.us-west.bsky.network',
apiKey: process.env.JETSTREAM_API_KEY,
})