This doc describes a real-time endorsement indexer using Bluesky's Jetstream service. It's the next evolution when the current per-follow PDS query approach becomes a bottleneck.
The current approach collects endorsements via a single pass over the user's
follows: one Slingshot resolveMiniDoc per follow (to find their PDS) + one
PDS listRecords per follow (to fetch their fund.at.endorse records). This
gives complete network data with zero new infrastructure, but:
- At high follow counts, O(follows) PDS queries add scan latency
- Results are cached per-session with a fingerprint key — cache misses trigger full recollection
- Endorsements from non-follows (the broader network) are invisible
- Each scan rediscovers the same endorsement data
A Jetstream listener eliminates all scan-time API calls — everything reads from Redis.
┌─────────────┐ WebSocket ┌──────────────┐
│ Jetstream │ ─────────────────▶ │ Collector │
│ (public) │ fund.at.endorse │ (Fly.io) │
└─────────────┘ └──────┬───────┘
│ writes
▼
┌──────────────┐
│ Upstash Redis │
└──────┬───────┘
│ reads
▼
┌──────────────┐
│ at.fund │
│ (Vercel) │
└──────────────┘
A ~100 line Node.js script deployed on Fly.io free tier:
- Connect to
wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=fund.at.endorse - Process each event:
create: normalize the endorsed URI, add the author DID to a Redis setdelete: remove the author DID from the set
- Redis data model:
endorse:dids:<normalized-uri>→ Redis Set of endorser DIDsendorse:cursor→ last processedtime_usfor gapless reconnection
- Reconnection: on disconnect, resume from saved cursor (subtract 5s safety buffer)
const ws = new WebSocket(
'wss://jetstream2.us-east.bsky.network/subscribe' +
'?wantedCollections=fund.at.endorse'
)
ws.on('message', async (raw) => {
const event = JSON.parse(raw)
if (event.kind !== 'commit') return
const uri = normalizeStewardUri(event.commit.record?.uri)
if (!uri) return
const key = `endorse:dids:${uri}`
if (event.commit.operation === 'create') {
await redis.sadd(key, event.did)
} else if (event.commit.operation === 'delete') {
await redis.srem(key, event.did)
}
await redis.set('endorse:cursor', event.time_us)
})// Total endorsement count
const count = await redis.scard(`endorse:dids:${uri}`)
// Network endorsement count (intersection with follows)
const endorserDids = await redis.smembers(`endorse:dids:${uri}`)
const networkCount = endorserDids.filter(did => followDids.has(did)).lengthJetstream only provides live events + ~24h replay buffer. For historical data:
- Initial backfill from current approach: Use the existing Slingshot + PDS
listRecordscollection to populate Redis sets for all known endorsers - Switch to Jetstream: Once caught up, the collector maintains the index
- PDS re-scan as fallback: If the collector goes down for >24h (beyond Jetstream's replay window), re-backfill using the per-follow PDS approach
- Platform: Fly.io free tier (1 shared CPU, 256MB RAM — more than enough)
- Cost: $0/month for the collector; existing Upstash Redis free tier
- Monitoring: Fly.io health checks + a simple
/healthendpoint - Scaling: Single instance is sufficient — fund.at.endorse volume is low
- Deploy collector alongside existing per-follow PDS queries
- Verify Redis data matches PDS query results
- Switch at.fund to read from Redis instead of querying PDS per follow
- Keep PDS approach as a fallback for cache misses
Consider building when:
- Follow counts are high enough that per-follow PDS queries add noticeable latency (>5s)
- We want endorsement data from beyond the user's immediate follow graph
- We need sub-minute endorsement freshness
- PDS rate limiting becomes an issue at scale
The current per-follow PDS approach is sufficient until the network has thousands of active endorsers.