← Back to DevBytes

Designing a Social Media Feed Serverless Architecture

Understanding the Social Media Feed Serverless Architecture

A social media feed is the beating heart of any social platform — it's the continuously updating stream of content that keeps users scrolling, engaging, and returning. Building this feature using a serverless architecture means designing a system that can handle millions of concurrent users, bursts of viral activity, and unpredictable traffic patterns without maintaining persistent infrastructure or paying for idle compute.

In a serverless feed architecture, every component scales independently based on demand. When a celebrity posts a tweet that generates 100,000 impressions in seconds, the system automatically provisions the necessary compute to fan-out that content, then scales back down to near-zero cost during quiet periods. This elasticity is the fundamental promise of serverless design for social feeds.

Core Components of a Serverless Feed System

A production-grade serverless feed typically consists of these interconnected services:

Why a Serverless Feed Architecture Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Traditional feed systems built on always-on server fleets face three critical challenges that serverless directly addresses:

1. The Cold Start vs. Always-On Cost Dilemma

Social platforms experience extreme traffic spikiness. A typical user checks their feed in 2-5 minute sessions, often during commute hours. Between these peaks, traditional infrastructure sits idle but still accrues compute, memory, and operational costs. Serverless converts this into a per-request billing model — you pay only when feeds are being assembled and served.

2. Fan-Out Complexity at Scale

When a user with 50,000 followers posts an update, that single write must propagate to 50,000 follower feeds. In a serverless model, this fan-out can be distributed across thousands of parallel Lambda invocations, each handling a subset of followers. The operation completes in seconds rather than minutes, and you never provision for peak fan-out load.

3. Geographic Distribution

Feeds must be fast everywhere. Serverless components like DynamoDB global tables, Lambda@Edge, and CloudFront enable feed data to live close to users without managing multi-region server deployments.

Feed Design Patterns: Fan-Out on Write vs. Fan-Out on Read

The fundamental architectural decision for any social feed is when to perform the fan-out operation. This choice shapes your entire serverless design.

Pattern A: Fan-Out on Write (Push Model)

When a post is created, immediately push it to the feed table of every follower. Read operations are lightning-fast — a simple query against the reader's personal feed partition.

Serverless implementation:

// post-creator Lambda — triggered via API Gateway
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
const sqs = new AWS.SQS();

exports.handler = async (event) => {
  const { userId, content, mediaUrls } = JSON.parse(event.body);
  const postId = generateUUID();
  const timestamp = Date.now();
  
  // 1. Write the post to the Posts table
  await dynamodb.put({
    TableName: 'Posts',
    Item: {
      postId,
      userId,
      content,
      mediaUrls,
      timestamp,
      likesCount: 0,
      commentsCount: 0
    }
  }).promise();

  // 2. Fan-out: push a message to SQS for async processing
  await sqs.sendMessage({
    QueueUrl: process.env.FANOUT_QUEUE_URL,
    MessageBody: JSON.stringify({
      postId,
      userId,
      timestamp,
      operation: 'FAN_OUT_NEW_POST'
    })
  }).promise();

  return {
    statusCode: 201,
    body: JSON.stringify({ postId, message: 'Post created, distributing to followers' })
  };
};

The fan-out worker Lambda processes the SQS queue, batching follower updates efficiently:

// fanout-worker Lambda — triggered by SQS
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();

exports.handler = async (event) => {
  for (const record of event.Records) {
    const payload = JSON.parse(record.body);
    
    if (payload.operation === 'FAN_OUT_NEW_POST') {
      // Fetch all followers of the post author
      const followers = await fetchAllFollowers(payload.userId);
      
      // DynamoDB supports batch writes of up to 25 items
      const batches = chunkArray(followers, 25);
      
      for (const batch of batches) {
        const writeRequests = batch.map(followerId => ({
          PutRequest: {
            TableName: 'UserFeeds',
            Item: {
              userId: followerId,
              postId: payload.postId,
              authorId: payload.userId,
              timestamp: payload.timestamp,
              // Store a pointer to the full post for enrichment
              postReference: payload.postId
            }
          }
        }));
        
        await dynamodb.batchWrite({
          RequestItems: {
            'UserFeeds': writeRequests
          }
        }).promise();
      }
    }
  }
};

async function fetchAllFollowers(userId) {
  // Query the Followers table or social graph
  // In production, use pagination for users with millions of followers
  const result = await dynamodb.query({
    TableName: 'Followers',
    KeyConditionExpression: 'followedUserId = :uid',
    ExpressionAttributeValues: { ':uid': userId },
    ProjectionExpression: 'followerId'
  }).promise();
  
  return result.Items.map(item => item.followerId);
}

function chunkArray(array, size) {
  const chunks = [];
  for (let i = 0; i < array.length; i += size) {
    chunks.push(array.slice(i, i + size));
  }
  return chunks;
}

Pattern B: Fan-Out on Read (Pull Model)

Store only the post itself at write time. When a user requests their feed, dynamically assemble it by querying the posts of everyone they follow. This eliminates write-time fan-out costs but demands more compute at read time.

Serverless implementation with smart caching:

// feed-reader Lambda — triggered via API Gateway
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
const redis = require('redis');
const { promisify } = require('util');

// DAX or ElastiCache client for hot feed caching
let redisClient;

exports.handler = async (event) => {
  const userId = event.pathParameters.userId;
  const limit = parseInt(event.queryStringParameters?.limit || '20');
  const cursor = event.queryStringParameters?.cursor || null;
  
  // 1. Check cache first — most feeds are read repeatedly
  const cacheKey = `feed:${userId}:${cursor || 'top'}`;
  
  if (!redisClient) {
    redisClient = new redis.AWSRedisClient({
      host: process.env.REDIS_HOST,
      port: 6379
    });
  }
  
  const cachedFeed = await promisify(redisClient.get).bind(redisClient)(cacheKey);
  if (cachedFeed) {
    return {
      statusCode: 200,
      body: JSON.stringify({ feed: JSON.parse(cachedFeed), source: 'cache' })
    };
  }
  
  // 2. Fetch the user's follow list
  const followingResult = await dynamodb.query({
    TableName: 'SocialGraph',
    KeyConditionExpression: 'userId = :uid AND relationship = :rel',
    ExpressionAttributeValues: { 
      ':uid': userId,
      ':rel': 'following'
    },
    Limit: 1000 // reasonable max follows for a single query
  }).promise();
  
  const followingIds = followingResult.Items.map(item => item.followedUserId);
  
  // 3. Fetch recent posts from each followed user
  // In production, use parallel queries with Promise.all
  const postPromises = followingIds.map(followedId => 
    dynamodb.query({
      TableName: 'Posts',
      IndexName: 'user-timestamp-index',
      KeyConditionExpression: 'userId = :uid AND timestamp > :since',
      ExpressionAttributeValues: {
        ':uid': followedId,
        ':since': Date.now() - (24 * 60 * 60 * 1000) // last 24 hours
      },
      Limit: 5, // get 5 recent posts per followed user
      ScanIndexForward: false // descending order
    }).promise()
  );
  
  const allPostsResults = await Promise.all(postPromises);
  
  // 4. Merge, sort, and paginate
  const mergedPosts = allPostsResults
    .flatMap(result => result.Items)
    .sort((a, b) => b.timestamp - a.timestamp)
    .slice(0, limit);
  
  // 5. Enrich posts with user profile data
  const enrichedPosts = await enrichPostsWithUserData(mergedPosts);
  
  // 6. Cache the assembled feed for 60 seconds
  await promisify(redisClient.setex).bind(redisClient)(
    cacheKey, 
    60, 
    JSON.stringify(enrichedPosts)
  );
  
  return {
    statusCode: 200,
    body: JSON.stringify({ 
      feed: enrichedPosts, 
      source: 'computed',
      nextCursor: enrichedPosts.length > 0 ? enrichedPosts[enrichedPosts.length - 1].timestamp : null
    })
  };
};

async function enrichPostsWithUserData(posts) {
  const userIds = [...new Set(posts.map(p => p.userId))];
  const userPromises = userIds.map(uid =>
    dynamodb.get({
      TableName: 'Users',
      Key: { userId: uid },
      ProjectionExpression: 'userId, displayName, avatarUrl'
    }).promise()
  );
  
  const users = await Promise.all(userPromises);
  const userMap = {};
  users.forEach(u => { userMap[u.Item.userId] = u.Item; });
  
  return posts.map(post => ({
    ...post,
    author: userMap[post.userId] || { displayName: 'Unknown' }
  }));
}

The Hybrid Model: Intelligent Fan-Out

Production social networks rarely use pure push or pull. Instead, they implement a hybrid model that classifies users based on activity patterns:

Here's how to implement the classification logic:

// post-router Lambda — determines fan-out strategy based on follower count
exports.handler = async (event) => {
  const { userId, content } = JSON.parse(event.body);
  
  // Get follower count from a pre-computed counter
  const followerCount = await getFollowerCount(userId);
  
  const postItem = {
    postId: generateUUID(),
    userId,
    content,
    timestamp: Date.now()
  };
  
  await dynamodb.put({ TableName: 'Posts', Item: postItem }).promise();
  
  if (followerCount > 100000) {
    // Hot user: store in a "celebrity posts" feed, no fan-out
    await dynamodb.put({
      TableName: 'CelebrityPosts',
      Item: { ...postItem, followerCount }
    }).promise();
    
    // Notify only "close friends" or "ring" subscribers
    await notifyRingSubscribers(userId, postItem);
    
  } else if (followerCount > 1000) {
    // Warm user: async fan-out via SQS with batching
    await sqs.sendMessage({
      QueueUrl: process.env.WARM_FANOUT_QUEUE_URL,
      MessageBody: JSON.stringify({ postId: postItem.postId, userId })
    }).promise();
    
  } else {
    // Cold user: synchronous fan-out, immediate consistency
    const followers = await fetchAllFollowers(userId);
    await batchWriteToFeeds(followers, postItem);
  }
  
  return { statusCode: 201, body: JSON.stringify({ postId: postItem.postId }) };
};

Real-Time Feed Updates with WebSockets

A truly engaging feed doesn't require manual refresh. API Gateway WebSocket support enables push notifications for new content:

// websocket-connect Lambda — establishes persistent connection
exports.handler = async (event) => {
  const connectionId = event.requestContext.connectionId;
  const userId = event.queryStringParameters.userId;
  
  // Register the connection in DynamoDB
  await dynamodb.put({
    TableName: 'WebSocketConnections',
    Item: {
      connectionId,
      userId,
      connectedAt: Date.now(),
      ttl: Math.floor(Date.now() / 1000) + 86400 // 24 hour TTL
    }
  }).promise();
  
  return { statusCode: 200 };
};

// websocket-notifier Lambda — called when new posts are ready
exports.handler = async (event) => {
  const { userId, postData } = event;
  const apiGateway = new AWS.ApiGatewayManagementApi({
    endpoint: process.env.WEBSOCKET_ENDPOINT
  });
  
  // Find all active connections for this user
  const connections = await dynamodb.query({
    TableName: 'WebSocketConnections',
    IndexName: 'user-index',
    KeyConditionExpression: 'userId = :uid',
    ExpressionAttributeValues: { ':uid': userId }
  }).promise();
  
  const postPromises = connections.Items.map(conn =>
    apiGateway.postToConnection({
      ConnectionId: conn.connectionId,
      Data: JSON.stringify({
        type: 'NEW_POST',
        payload: postData
      })
    }).promise().catch(async (err) => {
      // Clean up stale connections
      if (err.statusCode === 410) {
        await dynamodb.delete({
          TableName: 'WebSocketConnections',
          Key: { connectionId: conn.connectionId }
        }).promise();
      }
    })
  );
  
  await Promise.all(postPromises);
};

Handling the Feed Enrichment Problem

A raw feed of post IDs isn't useful. Users expect to see author avatars, like counts, comment previews, and whether they've already engaged. Enrichment is the most expensive part of feed serving. Here's a serverless pattern for efficient enrichment:

// enrichment Lambda — decorates feed items with user-specific context
exports.handler = async (event) => {
  const { userId, postIds } = event;
  
  // Parallel batch get for all posts
  const postKeys = postIds.map(id => ({ postId: id }));
  const postBatch = await dynamodb.batchGet({
    RequestItems: {
      'Posts': { Keys: postKeys }
    }
  }).promise();
  
  const posts = postBatch.Responses['Posts'];
  
  // Extract unique author IDs and media references
  const authorIds = [...new Set(posts.map(p => p.userId))];
  const mediaRefs = posts.filter(p => p.mediaUrls).flatMap(p => p.mediaUrls);
  
  // Parallel enrichment queries
  const [authors, engagement, mediaUrls] = await Promise.all([
    // Author profiles
    dynamodb.batchGet({
      RequestItems: {
        'Users': { 
          Keys: authorIds.map(id => ({ userId: id })),
          ProjectionExpression: 'userId, displayName, avatarUrl, verifiedBadge'
        }
      }
    }).promise(),
    
    // User's engagement with these posts
    dynamodb.batchGet({
      RequestItems: {
        'UserEngagement': {
          Keys: postIds.map(pid => ({
            userId_postId: `${userId}#${pid}`
          })),
          ProjectionExpression: 'postId, liked, commented, shared'
        }
      }
    }).promise(),
    
    // Pre-signed media URLs (if stored in S3)
    generateSignedUrls(mediaRefs)
  ]);
  
  // Assemble enriched feed items
  return posts.map(post => {
    const author = authors.Responses['Users'].find(u => u.userId === post.userId);
    const userEngagement = engagement.Responses['UserEngagement'].find(
      e => e.postId === post.postId
    );
    
    return {
      postId: post.postId,
      content: post.content,
      timestamp: post.timestamp,
      author: {
        displayName: author?.displayName || 'Unknown User',
        avatarUrl: author?.avatarUrl,
        verified: author?.verifiedBadge || false
      },
      metrics: {
        likesCount: post.likesCount || 0,
        commentsCount: post.commentsCount || 0
      },
      userContext: {
        hasLiked: userEngagement?.liked || false,
        hasCommented: userEngagement?.commented || false
      },
      mediaUrls: mediaUrls.filter(m => post.mediaUrls?.includes(m.originalKey))
    };
  });
};

Feed Personalization and Ranking

Not all posts are equal. Modern feeds use machine learning for ranking. In serverless, you can integrate ML inference without managing GPU instances:

// ranking Lambda — scores and sorts feed items
const AWS = require('aws-sdk');
const sagemakerRuntime = new AWS.SageMakerRuntime();

exports.handler = async (event) => {
  const { userId, feedItems } = event;
  
  if (feedItems.length === 0) return feedItems;
  
  // Prepare features for the ranking model
  const features = feedItems.map(item => ({
    postId: item.postId,
    features: {
      authorAffinity: await computeAuthorAffinity(userId, item.authorId),
      recencyScore: Math.exp(-(Date.now() - item.timestamp) / (1000 * 3600)),
      engagementVelocity: item.likesCount / Math.max(1, hoursSincePost(item.timestamp)),
      contentSimilarity: await computeContentSimilarity(userId, item.content),
      followerOverlap: await computeFollowerOverlap(userId, item.authorId)
    }
  }));
  
  // Call SageMaker endpoint for inference
  const sagmakerResponse = await sagemakerRuntime.invokeEndpoint({
    EndpointName: process.env.RANKING_ENDPOINT_NAME,
    ContentType: 'application/json',
    Body: JSON.stringify({
      userId,
      items: features
    })
  }).promise();
  
  const scores = JSON.parse(sagemakerResponse.Body.toString());
  
  // Merge scores back and sort
  const rankedItems = feedItems
    .map((item, index) => ({ ...item, relevanceScore: scores[index] }))
    .sort((a, b) => b.relevanceScore - a.relevanceScore);
  
  return rankedItems;
};

async function computeAuthorAffinity(userId, authorId) {
  // Check interaction history from a pre-computed affinity table
  const result = await dynamodb.get({
    TableName: 'UserAffinity',
    Key: { userId_authorId: `${userId}#${authorId}` }
  }).promise();
  
  return result.Item?.affinityScore || 0.1; // default for new relationships
}

Handling the "Infinite Scroll" Pagination Pattern

Feeds are consumed as infinite scrolls. Cursor-based pagination in DynamoDB requires careful key design:

// pagination handler — returns next page of feed
exports.handler = async (event) => {
  const userId = event.pathParameters.userId;
  const cursor = event.queryStringParameters?.cursor; // base64 encoded
  const limit = 20;
  
  let cursorData;
  if (cursor) {
    try {
      cursorData = JSON.parse(Buffer.from(cursor, 'base64').toString());
    } catch (e) {
      return { statusCode: 400, body: JSON.stringify({ error: 'Invalid cursor' }) };
    }
  }
  
  const params = {
    TableName: 'UserFeeds',
    KeyConditionExpression: 'userId = :uid',
    ExpressionAttributeValues: { ':uid': userId },
    Limit: limit,
    ScanIndexForward: false // newest first
  };
  
  if (cursorData) {
    params.ExclusiveStartKey = {
      userId,
      timestamp_postId: cursorData.lastKey
    };
  }
  
  const result = await dynamodb.query(params).promise();
  
  const nextCursor = result.LastEvaluatedKey 
    ? Buffer.from(JSON.stringify({ 
        lastKey: result.LastEvaluatedKey.timestamp_postId 
      })).toString('base64')
    : null;
  
  return {
    statusCode: 200,
    body: JSON.stringify({
      items: result.Items,
      nextCursor,
      hasMore: !!nextCursor
    })
  };
};

Best Practices for Serverless Feed Architecture

1. Design Your Partition Keys for Feed Access Patterns

The most common mistake is putting all posts in a single partition. Instead, design DynamoDB tables around how feeds are queried:

// Good: UserFeeds table with userId as partition key
// Each user's feed lives in its own partition, preventing hot partitions
TableName: 'UserFeeds'
PrimaryKey: {
  partitionKey: 'userId',
  sortKey: 'timestamp_postId'  // composite sort for time-based ordering
}

// Good: Posts table with GSI for author-based queries
TableName: 'Posts'
PrimaryKey: { partitionKey: 'postId' }
GlobalSecondaryIndexes: [{
  IndexName: 'author-timestamp-index',
  partitionKey: 'userId',
  sortKey: 'timestamp'
}]

2. Implement Graceful Degradation

When fan-out is incomplete (e.g., a user with millions of followers is still being processed), the feed should still render. Show a "new posts are being added" indicator rather than blocking:

// Graceful degradation in feed assembly
async function assembleFeedWithDegradation(userId, limit) {
  // Primary source: pre-computed feed
  const feedResult = await dynamodb.query({
    TableName: 'UserFeeds',
    KeyConditionExpression: 'userId = :uid',
    ExpressionAttributeValues: { ':uid': userId },
    Limit: limit,
    ScanIndexForward: false
  }).promise();
  
  // If feed is sparse (fan-out in progress), supplement with pull-based queries
  if (feedResult.Items.length < limit) {
    const supplementalPosts = await pullRecentPostsFromFollowedUsers(
      userId, 
      limit - feedResult.Items.length
    );
    
    // Deduplicate and merge
    const existingPostIds = new Set(feedResult.Items.map(i => i.postId));
    const uniqueSupplement = supplementalPosts.filter(p => !existingPostIds.has(p.postId));
    
    return [...feedResult.Items, ...uniqueSupplement];
  }
  
  return feedResult.Items;
}

3. Use Step Functions for Complex Fan-Out Workflows

For celebrity posts with millions of followers, a simple Lambda may time out. Step Functions orchestrate the multi-stage fan-out with automatic retries:

// Step Function definition for large-scale fan-out
{
  "Comment": "Fan-out workflow for high-follower-count posts",
  "StartAt": "FetchFollowerBatches",
  "States": {
    "FetchFollowerBatches": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:...",
      "ResultPath": "$.batches",
      "Next": "DistributeBatches"
    },
    "DistributeBatches": {
      "Type": "Map",
      "ItemsPath": "$.batches",
      "MaxConcurrency": 50,
      "Iterator": {
        "StartAt": "WriteBatchToFeeds",
        "States": {
          "WriteBatchToFeeds": {
            "Type": "Task",
            "Resource": "arn:aws:lambda:...",
            "End": true
          }
        }
      },
      "Next": "NotifyCompletion"
    },
    "NotifyCompletion": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:...",
      "End": true
    }
  }
}

4. Cache Aggressively, Invalidate Precisely

Feed reads can be 100x more frequent than writes. Implement multi-level caching:

// Multi-level cache strategy
const cacheStrategy = {
  // L1: In-memory DAX cache for hottest feeds (microsecond latency)
  dax: { ttl: 30 }, // 30 seconds for rapidly changing feeds
  
  // L2: ElastiCache Redis for warm feeds (sub-millisecond latency)
  redis: { ttl: 300 }, // 5 minutes for moderately active feeds
  
  // L3: CloudFront edge cache for anonymous/public feeds
  cloudfront: { ttl: 60, staleWhileRevalidate: 300 },
  
  // Invalidation triggers:
  invalidateOn: ['NEW_POST_BY_FOLLOWED_USER', 'POST_DELETED', 'POST_UPDATED']
};

// Cache invalidation Lambda triggered by DynamoDB Streams
exports.handler = async (event) => {
  for (const record of event.Records) {
    if (record.eventName === 'INSERT') {
      const newPost = AWS.DynamoDB.Converter.unmarshall(record.dynamodb.NewImage);
      
      // Invalidate feeds of all followers
      const followers = await fetchAllFollowers(newPost.userId);
      const cacheKeys = followers.map(fid => `feed:${fid}:*`);
      
      // Use Redis SCAN + DEL pattern for wildcard invalidation
      for (const pattern of chunkArray(cacheKeys, 100)) {
        await redisClient.eval(
          "local keys = redis.call('KEYS', ARGV[1]); for i, key in ipairs(keys) do redis.call('DEL', key); end; return keys;",
          0,
          pattern.join(' ')
        );
      }
    }
  }
};

5. Monitor and Observe Fan-Out Progress

When fan-out is asynchronous, users and operations teams need visibility into progress:

// Fan-out progress tracking
async function trackFanOutProgress(postId, totalFollowers, completedBatches) {
  const progressPercentage = Math.round((completedBatches / totalFollowers) * 100);
  
  await dynamodb.update({
    TableName: 'FanOutProgress',
    Key: { postId },
    UpdateExpression: 'SET completedBatches = :c, progressPercent = :p, lastUpdate = :t',
    ExpressionAttributeValues: {
      ':c': completedBatches,
      ':p': progressPercentage,
      ':t': Date.now()
    }
  }).promise();
  
  // Emit progress via WebSocket to content creator
  if (progressPercentage % 25 === 0) { // milestone notifications
    await notifyPostAuthor(postId, {
      type: 'FAN_OUT_PROGRESS',
      payload: { postId, progress: progressPercentage }
    });
  }
}

6. Implement Idempotency at Every Layer

Serverless functions can be invoked multiple times. Every write operation must be idempotent:

// Idempotent post creation
exports.handler = async (event) => {
  const { userId, content, idempotencyKey } = JSON.parse(event.body);
  
  // Check if this idempotency key has already been processed
  const existingPost = await dynamodb.get({
    TableName: 'IdempotencyLedger',
    Key: { idempotencyKey }
  }).promise();
  
  if (existingPost.Item) {
    return {
      statusCode: 200,
      body: JSON.stringify({ 
        postId: existingPost.Item.postId,
        message: 'Post already exists (idempotent response)' 
      })
    };
  }
  
  const postId = generateUUID();
  
  // Use a transaction to atomically create both the post and idempotency record
  await dynamodb.transactWrite({
    TransactItems: [
      {
        Put: {
          TableName: 'Posts',
          Item: { postId, userId, content, timestamp: Date.now() },
          ConditionExpression: 'attribute_not_exists(postId)'
        }
      },
      {
        Put: {
          TableName: 'IdempotencyLedger',
          Item: { 
            idempotencyKey, 
            postId, 
            createdAt: Date.now(),
            ttl: Math.floor(Date.now() / 1000) + 86400 // expire after 24h
          }
        }
      }
    ]
  }).promise();
  
  return { statusCode: 201, body: JSON.stringify({ postId }) };
};

Cost Optimization for Production Feeds

A well-designed serverless feed can serve millions of users for a fraction of traditional infrastructure costs. Here's how to optimize:

// CloudFormation snippet for cost-optimized DynamoDB feed table
{
  "UserFeedsTable": {
    "Type": "AWS::DynamoDB::Table",
    "Properties": {
      "TableName": "UserFeeds",
      "BillingMode": "PAY_PER_REQUEST",
      "AttributeDefinitions": [
        { "AttributeName": "userId", "AttributeType": "S" },
        { "AttributeName": "timestamp_postId", "AttributeType": "S" }
      ],
      "KeySchema": [
        { "AttributeName": "userId", "KeyType": "HASH" },
        { "AttributeName": "timestamp_postId", "KeyType": "RANGE" }
      ],
      "TimeToLiveSpecification": {
        "AttributeName": "ttl",
        "Enabled": true
      },
      "PointInTimeRecoverySpecification": {
        "PointInTimeRecoveryEnabled": true
      }
    }
  }
}

Security Considerations for Social Feeds

Feeds expose sensitive social graph data. Serverless security requires defense in depth:

// Authorization middleware for feed access
exports.handler = async (event) => {
  const userId = event.pathParameters.userId;
  const requestorId = event.requestContext.authorizer.userId;
  
  // Verify the requestor has permission to view this feed
  const relationship = await checkRelationship(requestorId, userId);
  
  if (!relationship.allowed) {
    return {
      statusCode: 403,
      body: JSON.stringify({ 
        error: 'You do not have permission to view this feed',
        reason: relationship.reason // 'BLOCKED', 'PRIVATE_ACCOUNT', etc.
      })
    };
  }
  
  // Apply visibility filters based on relationship type
  const visibilityFilter = relationship.isCloseFriend 
    ? 'ALL_POSTS' 
    : 'PUBLIC_POSTS_ONLY';
  
  // Continue with feed assembly using the appropriate filter
  return assembleFeed(userId, visibilityFilter);
};

async function checkRelationship(requestorId, targetUserId) {
  const result = await dynamodb.get({
    TableName: 'Relationships',
    Key: { 
      userId_requestorId: `${targetUserId}#${requestorId}` 
    }
  }).promise();
  
  if (!result.Item) {
    return { allowed: true, reason: 'PUBLIC_PROFILE' };
  }
  
  if (result.Item.relationship === 'BLOCKED') {
    return { allowed: false, reason: 'BLOCKED' };
  }
  
  return { 
    allowed: true, 
    isCloseFriend: result.Item.relationship === 'CLOSE_FRIEND'
  };
}

Conclusion

Designing a social media feed on serverless architecture is fundamentally about embracing asynchronous processing, intelligent fan-out strategies, and multi-level caching. The patterns presented here — fan-out on write for warm users, fan-out on read for hot users, enrichment through parallel batch operations, and real-time updates via WebSockets — form a production-ready foundation that scales from zero to millions of users without operational overhead.

The key insight is that no single pattern fits all users. By classifying users based on follower count and activity patterns, you can route each post through the most cost-effective and performant pipeline. Serverless services like Lambda, DynamoDB, SQS, and API Gateway give you the primitives to build this routing logic without ever provisioning a server. The result is a feed system that delivers sub-second response times during viral spikes, costs near-zero during quiet periods, and gracefully degrades when fan-out is still in progress — exactly the behavior users expect from modern social platforms.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles