Skip to main content

Verified against Mastodon v4.7.3 delivery behaviour (via Delivery at Scale), BullMQ and Bull READMEs, undici README, Mastodon 4.7.0 release notes (inbox collections) on 2026-10-15.

Scaling and Performance

Federation presents unique scaling challenges: every post fans out to one HTTP request per receiving inbox, and every incoming activity may trigger fetches. This page covers the server-side architecture; for retry schedules, terminal errors, dead-server tracking and circuit breakers see Delivery at Scale, which documents what Mastodon actually does.

The Scaling Challenge​

┌────────────────────────────────────────────────────────┐
│ FEDERATION SCALING PROBLEM │
├────────────────────────────────────────────────────────┤
│ │
│ 1 post × 1000 followers across 500 servers │
│ = 500 outgoing HTTP requests │
│ │
│ Each request needs: │
│ - DNS resolution │
│ - TLS handshake │
│ - HTTP signature generation │
│ - Response handling │
│ - Retry logic for failures │
│ │
│ Popular accounts can generate massive fan-out │
│ │
└────────────────────────────────────────────────────────┘

Delivery Queue Architecture​

Basic Queue Implementation​

// Don't deliver synchronously!
// BAD:
async function createPost(author, content) {
const note = await saveNote(author, content);
for (const follower of await getFollowers(author)) {
await deliverActivity(note, follower.inbox); // Blocks!
}
return note;
}

// GOOD:
async function createPost(author, content) {
const note = await saveNote(author, content);
const activity = wrapInCreate(note);

// Queue delivery for async processing
await queue.add('delivery', {
activity,
actor: author.id
});

return note;
}

Queue Worker​

Fan out in two steps: one job resolves the recipients, then one job per inbox does the delivery, so a slow or dead server only delays its own job and a retry resends to one server. The example uses BullMQ (the older bull package is in maintenance mode):

import { Queue, Worker } from 'bullmq';

const connection = { host: 'localhost', port: 6379 };
const fanoutQueue = new Queue('fanout', { connection });
const deliveryQueue = new Queue('delivery', { connection });

// Step 1: resolve recipients once, enqueue one job per unique inbox
new Worker('fanout', async (job) => {
const { activity, actorId } = job.data;
const recipients = await resolveRecipients(activity); // actor documents
for (const inbox of deduplicateInboxes(recipients)) {
await deliveryQueue.add('deliver', { activity, actorId, inbox }, {
attempts: 17, // first try + 16 retries, like Mastodon
backoff: { type: 'custom' },
});
}
}, { connection });

// Step 2: deliver; throw to retry, return to finish
new Worker('delivery', async (job) => {
const { activity, actorId, inbox } = job.data;
if (!(await shouldDeliverTo(new URL(inbox).hostname))) return;

const outcome = await deliverOnce(activity, inbox, actorId); // 'success' | 'permanent' | 'retry'
if (outcome === 'retry') throw new Error(`retry ${inbox}`);
}, {
connection,
concurrency: 10,
settings: {
// Mastodon-like curve: n^4 + 15 s plus jitter (see Delivery at Scale)
backoffStrategy: (attemptsMade) =>
((attemptsMade - 1) ** 4 + 15 + Math.random() * 10) * 1000,
},
});

deliverOnce and the success/permanent/retry classification are shown in Delivery at Scale.

Shared Inbox Optimization​

Use shared inboxes to reduce requests:

function deduplicateInboxes(recipients) {
const inboxMap = new Map();

for (const recipient of recipients) {
// Prefer sharedInbox over personal inbox
const inbox = recipient.endpoints?.sharedInbox || recipient.inbox;

if (!inboxMap.has(inbox)) {
inboxMap.set(inbox, []);
}
inboxMap.get(inbox).push(recipient.id);
}

return Array.from(inboxMap.keys());
}

With shared inbox, 1000 followers on 500 servers becomes ~500 requests instead of 1000.

Retry Strategies​

Retry from the queue, not with sleep() inside a worker — a sleeping worker holds a concurrency slot for nothing. Retry over days, not seconds: Mastodon retries a delivery 16 times over roughly 2–3 days and gives up immediately on most 4xx responses (except 401, 408 and 429). Details and the exact curve: Delivery at Scale → Retry schedule.

Dead Server Tracking​

Count failures by distinct days, and clear the record on any success — Mastodon marks a host unavailable after failures on 7 different days, and makes it deliverable again as soon as a delivery succeeds or the host sends a signed request (Delivery at Scale → Unavailable domains):

async function recordFailure(hostname) {
const day = new Date().toISOString().slice(0, 10);
const { days } = await db.serverStatus.upsert(
{ hostname },
{ $addToSet: { days: day } },
{ returnDocument: 'after' }
);
if (days.length >= 7) {
await db.serverStatus.update({ hostname }, { $set: { unavailable: true } });
}
}

async function recordSuccess(hostname) {
await db.serverStatus.remove({ hostname });
}

async function shouldDeliverTo(hostname) {
const status = await db.serverStatus.findOne({ hostname });
return !status?.unavailable;
}

Database Optimization​

Indexing Strategy​

// Essential indexes for ActivityPub
db.notes.createIndex({ id: 1 }, { unique: true });
db.notes.createIndex({ attributedTo: 1, published: -1 });
db.notes.createIndex({ inReplyTo: 1 });
db.notes.createIndex({ 'to': 1 });
db.notes.createIndex({ 'tag.name': 1, 'tag.type': 1 });

db.actors.createIndex({ id: 1 }, { unique: true });
db.actors.createIndex({ preferredUsername: 1 });

db.followers.createIndex({ actor: 1 });
db.followers.createIndex({ follower: 1 });
db.followers.createIndex({ actor: 1, follower: 1 }, { unique: true });

db.inbox.createIndex({ actor: 1, createdAt: -1 });

Connection Pooling​

const { Pool } = require('pg');

const pool = new Pool({
host: 'localhost',
database: 'activitypub',
max: 20, // Max connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
});

// Use pool for all queries
async function query(text, params) {
const client = await pool.connect();
try {
return await client.query(text, params);
} finally {
client.release();
}
}

Caching​

const Redis = require('ioredis');
const redis = new Redis();

async function fetchActor(actorId) {
// Try cache first
const cached = await redis.get(`actor:${actorId}`);
if (cached) {
return JSON.parse(cached);
}

// Fetch from remote
const actor = await fetchRemoteActor(actorId);

// Cache for 1 hour
await redis.setex(`actor:${actorId}`, 3600, JSON.stringify(actor));

return actor;
}

// Invalidate on Update — any actor type, and only for the signer's own actor
const ACTOR_TYPES = new Set(['Person', 'Service', 'Application', 'Group', 'Organization']);

async function processUpdate(activity, signer) {
const object = activity.object;
if (ACTOR_TYPES.has(object?.type) && object.id === signer.id) {
await redis.del(`actor:${object.id}`);
}
// Process update...
}

HTTP Optimization​

Connection Keep-Alive​

Node's built-in fetch (undici) keeps connections alive by default and ignores the old agent option; tune it with an undici Agent passed as dispatcher. Capping connections per origin also stops a big fan-out from hammering one server:

import { Agent } from 'undici';

const dispatcher = new Agent({
connections: 8, // max sockets per origin
keepAliveTimeout: 30_000,
});

async function deliverActivity(activity, inbox, signedHeaders) {
return fetch(inbox, {
method: 'POST',
dispatcher,
headers: {
'Content-Type': 'application/activity+json',
...signedHeaders,
},
body: JSON.stringify(activity),
signal: AbortSignal.timeout(10_000),
});
}

Request Timeouts​

async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout

try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
return response;
} finally {
clearTimeout(timeout);
}
}

Infrastructure​

┌─────────────────────────────────────────────────────────┐
│ PRODUCTION SETUP │
├─────────────────────────────────────────────────────────┤
│ │
│ [Load Balancer] │
│ ↓ │
│ [Web Servers] ←→ [Redis] ←→ [Queue Workers] │
│ ↓ │
│ [PostgreSQL] (Primary + Read Replicas) │
│ ↓ │
│ [Object Storage] (Media files) │
│ ↓ │
│ [CDN] (Static assets, cached media) │
│ │
└─────────────────────────────────────────────────────────┘

Component Responsibilities​

ComponentPurpose
Load BalancerDistribute requests, SSL termination
Web ServersHandle HTTP requests, serve API
RedisSession store, cache, job queue
Queue WorkersProcess deliveries, background jobs
PostgreSQLPrimary data store
Object StorageMedia files (S3, Minio)
CDNCache static files, reduce origin load

Horizontal Scaling​

# docker-compose.yml for scaling
services:
web:
image: myapp
deploy:
replicas: 4
environment:
- DATABASE_URL=postgres://...
- REDIS_URL=redis://...

worker:
image: myapp
command: node worker.js
deploy:
replicas: 8 # More workers for delivery
environment:
- DATABASE_URL=postgres://...
- REDIS_URL=redis://...

redis:
image: redis:alpine

postgres:
image: postgres:15

Monitoring​

Key Metrics​

const prometheus = require('prom-client');

// Delivery metrics
const deliveryDuration = new prometheus.Histogram({
name: 'activitypub_delivery_duration_seconds',
help: 'Time to deliver activities',
buckets: [0.1, 0.5, 1, 2, 5, 10]
});

const deliveryErrors = new prometheus.Counter({
name: 'activitypub_delivery_errors_total',
help: 'Total delivery errors',
labelNames: ['error_type']
});

const queueSize = new prometheus.Gauge({
name: 'activitypub_queue_size',
help: 'Current delivery queue size'
});

// Use in delivery code
async function deliverActivity(activity, inbox, actor) {
const end = deliveryDuration.startTimer();
try {
await doDelivery(activity, inbox, actor);
} catch (error) {
deliveryErrors.inc({ error_type: error.code });
throw error;
} finally {
end();
}
}

Queue Monitoring​

// Monitor queue health
setInterval(async () => {
const waiting = await deliveryQueue.getWaitingCount();
const active = await deliveryQueue.getActiveCount();
const failed = await deliveryQueue.getFailedCount();

queueSize.set(waiting + active);

if (waiting > 10000) {
console.warn('Delivery queue backing up:', waiting);
}
}, 10000);

Optimization Checklist​

Before Launch​

  • Set up delivery queue (Redis + workers)
  • Configure connection pooling
  • Add database indexes
  • Set up caching layer
  • Configure timeouts on all HTTP requests
  • Set up monitoring and alerting

For Growth​

  • Use shared inboxes where possible
  • Implement dead server tracking
  • Add read replicas for database
  • Use CDN for media
  • Implement rate limiting
  • Debounce count updates (see below)

Batching and Debouncing​

There is no interoperable way to batch several activities into one delivery: Mastodon 4.7 stopped processing collections of activities posted to an inbox, and FEP-49eb (batched delivery) is still a draft — see Delivery at Scale → Batching. Every Like, vote or Follow is its own activity, and receivers expect it.

What you can debounce is your own outgoing Updates. Mastodon, for example, sends one Update of a poll about 3 minutes after votes arrive rather than one per vote (see Polls).

Next Steps​