Skip to main content

Scaling and Performance

Federation presents unique scaling challenges. When you have 100 federated servers and a post gets 100 comments with 100 votes each, that's potentially millions of HTTP requests. This guide covers strategies for building performant, scalable ActivityPub implementations.

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

const Queue = require('bull');

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

// Process deliveries with concurrency control
deliveryQueue.process(10, async (job) => {
const { activity, actor } = job.data;
const authorActor = await db.actors.findOne({ id: actor });

// Get all recipients
const inboxes = await resolveRecipientInboxes(activity);

// Deduplicate by shared inbox
const uniqueInboxes = deduplicateInboxes(inboxes);

// Deliver to each inbox
const results = await Promise.allSettled(
uniqueInboxes.map(inbox =>
deliverWithRetry(activity, inbox, authorActor)
)
);

// Log failures for retry
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
console.log(`${failures.length} deliveries failed`);
}
});

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

Exponential Backoff

async function deliverWithRetry(activity, inbox, actor, attempt = 0) {
const maxAttempts = 5;
const baseDelay = 1000; // 1 second

try {
await deliverActivity(activity, inbox, actor);
} catch (error) {
if (attempt >= maxAttempts) {
// Mark server as potentially dead
await markServerUnreachable(new URL(inbox).hostname);
throw error;
}

// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const delay = baseDelay * Math.pow(2, attempt);
await sleep(delay);

return deliverWithRetry(activity, inbox, actor, attempt + 1);
}
}

Dead Server Tracking

async function markServerUnreachable(hostname) {
await db.serverStatus.upsert(
{ hostname },
{
$set: { lastFailure: new Date() },
$inc: { failureCount: 1 }
}
);
}

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

if (!status) return true;

// If failed recently, skip
if (status.failureCount >= 5) {
const hoursSinceFailure =
(Date.now() - status.lastFailure) / (1000 * 60 * 60);

// Back off exponentially up to 24 hours
const backoffHours = Math.min(24, Math.pow(2, status.failureCount - 5));

if (hoursSinceFailure < backoffHours) {
return false;
}
}

return true;
}

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
async function processUpdate(activity) {
if (activity.object?.type === 'Person') {
await redis.del(`actor:${activity.object.id}`);
}
// Process update...
}

HTTP Optimization

Connection Keep-Alive

const https = require('https');
const http = require('http');

const httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 100,
maxFreeSockets: 10,
timeout: 60000
});

async function deliverActivity(activity, inbox, actor) {
const response = await fetch(inbox, {
method: 'POST',
agent: httpsAgent, // Reuse connections
headers: {
'Content-Type': 'application/activity+json',
// ... signature headers
},
body: JSON.stringify(activity)
});

return response;
}

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
  • Consider aggregating activities

Activity Aggregation

Reduce vote/like spam by aggregating:

// Instead of sending 100 Like activities immediately
// Batch them into periodic updates

async function recordLike(actor, objectId) {
// Store locally
await db.likes.insert({ actor: actor.id, object: objectId });

// Don't deliver immediately
}

// Periodic job to send aggregated notifications
async function sendLikeAggregates() {
const recentLikes = await db.likes.aggregate([
{ $match: { notified: false } },
{ $group: { _id: '$object', count: { $sum: 1 } } }
]);

for (const { _id: objectId, count } of recentLikes) {
// Send single notification: "Your post got 15 new likes"
// Instead of 15 separate Like activities
}
}

Next Steps