Back to Blog

SaaS Activity Feed in 2026: Fanout Architecture, Real-Time Updates, and Timeline Design

Build a production SaaS activity feed: fanout-on-write vs fanout-on-read tradeoffs, PostgreSQL schema, real-time updates with SSE, notification batching, and infinite scroll.

Viprasol Tech Team
January 8, 2027
14 min read

SaaS Activity Feed in 2026: Fanout Architecture, Real-Time Updates, and Timeline Design

The activity feedβ€”"Alice created a project", "Bob commented on your task", "Stripe payment succeeded"β€”is one of the most underestimated features to get right. Get the architecture wrong and you either run slow queries on every page load or burn database writes fanning out to every team member on every action.

This post covers the complete production implementation: activity schema, fanout strategy, real-time delivery with SSE, notification batching, and the React component that renders the feed with infinite scroll.


Fanout Strategy: Write vs Read

The fundamental question is when you compute "who sees this activity":

Fanout on Write

When an event occurs, immediately write one row per recipient into a user_activities table.

User creates project
    β†’ INSERT INTO user_activities (user_id=alice, ...) -- alice
    β†’ INSERT INTO user_activities (user_id=bob, ...)   -- bob (team member)
    β†’ INSERT INTO user_activities (user_id=carol, ...) -- carol (team member)

Pros: Feed reads are instant single-table queries by user_id. Perfect for small teams.
Cons: Write amplification. A team of 100 = 100 inserts per event. At scale this is expensive.

Fanout on Read

Store one canonical activity row. At read time, join with team/follower tables to determine relevance.

User creates project
    β†’ INSERT INTO activities (team_id=team-a, ...)  -- one row

Feed read:
    β†’ SELECT * FROM activities WHERE team_id IN (user's teams)

Pros: Write is cheap. No duplication.
Cons: Read query is more complex. Harder to personalize (e.g., "only show activity from projects I'm a member of").

Our recommendation: Fanout on write for teams under 50 members. Hybrid (write fanout only for notifications, read fanout for general feed) for larger products.


Database Schema

-- migrations/20260101_activity_feed.sql

-- Canonical activity log β€” one row per event regardless of audience
CREATE TABLE activities (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id         UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  actor_id        UUID NOT NULL REFERENCES users(id),
  
  -- What happened
  verb            TEXT NOT NULL,          -- 'created', 'updated', 'deleted', 'commented', 'invited'
  object_type     TEXT NOT NULL,          -- 'project', 'task', 'comment', 'invoice', 'member'
  object_id       TEXT NOT NULL,          -- ID of the affected object
  object_title    TEXT,                   -- Cached title (avoid JOIN on read)
  
  -- For linking from the feed
  target_url      TEXT,
  
  -- Rich metadata for rendering
  metadata        JSONB NOT NULL DEFAULT '{}'::jsonb,
  
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_activities_team ON activities(team_id, created_at DESC);
CREATE INDEX idx_activities_actor ON activities(actor_id, created_at DESC);
CREATE INDEX idx_activities_object ON activities(object_type, object_id);

-- Per-user notification inbox (fanout on write)
-- Only created for events that need personal notification
CREATE TABLE user_notifications (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  activity_id     UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
  
  -- Notification state
  is_read         BOOLEAN NOT NULL DEFAULT FALSE,
  read_at         TIMESTAMPTZ,
  is_dismissed    BOOLEAN NOT NULL DEFAULT FALSE,
  
  -- Notification type for grouping/batching
  notification_type TEXT NOT NULL,   -- 'mention', 'assigned', 'commented', 'invited'
  
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  
  UNIQUE (user_id, activity_id)
);

CREATE INDEX idx_notifications_user_unread ON user_notifications(user_id, is_read, created_at DESC)
  WHERE is_dismissed = FALSE;
CREATE INDEX idx_notifications_user ON user_notifications(user_id, created_at DESC);

πŸš€ SaaS MVP in 8 Weeks β€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment β€” all handled by one senior team.

  • Week 1–2: Architecture design + wireframes
  • Week 3–6: Core features built + tested
  • Week 7–8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

Activity Recording Service

// lib/activity/recorder.ts
import { db } from "@/lib/db";
import { notifyUsersOfActivity } from "./fanout";

export type Verb = "created" | "updated" | "deleted" | "commented" | "invited" | "assigned" | "completed";
export type ObjectType = "project" | "task" | "comment" | "invoice" | "member" | "integration";

interface RecordActivityParams {
  teamId: string;
  actorId: string;
  verb: Verb;
  objectType: ObjectType;
  objectId: string;
  objectTitle?: string;
  targetUrl?: string;
  metadata?: Record<string, unknown>;
  // Who should receive a personal notification (subset of team)
  notifyUserIds?: string[];
  notificationType?: string;
}

export async function recordActivity(params: RecordActivityParams) {
  const activity = await db.activity.create({
    data: {
      teamId: params.teamId,
      actorId: params.actorId,
      verb: params.verb,
      objectType: params.objectType,
      objectId: params.objectId,
      objectTitle: params.objectTitle,
      targetUrl: params.targetUrl,
      metadata: params.metadata ?? {},
    },
  });

  // Fan out notifications to specific users (non-blocking)
  if (params.notifyUserIds && params.notifyUserIds.length > 0) {
    notifyUsersOfActivity({
      activityId: activity.id,
      userIds: params.notifyUserIds.filter((id) => id !== params.actorId), // Don't notify the actor
      notificationType: params.notificationType ?? params.verb,
    }).catch((err) => console.error("Activity fanout failed:", err));
  }

  return activity;
}

Fanout worker:

// lib/activity/fanout.ts
import { db } from "@/lib/db";
import { sseManager } from "@/lib/sse/manager";

export async function notifyUsersOfActivity({
  activityId,
  userIds,
  notificationType,
}: {
  activityId: string;
  userIds: string[];
  notificationType: string;
}) {
  if (userIds.length === 0) return;

  // Batch insert notifications
  await db.userNotification.createMany({
    data: userIds.map((userId) => ({
      userId,
      activityId,
      notificationType,
    })),
    skipDuplicates: true, // Idempotent
  });

  // Push real-time notification to connected users
  for (const userId of userIds) {
    sseManager.send(userId, {
      type: "notification",
      activityId,
    });
  }
}

Integrating into Business Logic

// app/api/tasks/route.ts
import { recordActivity } from "@/lib/activity/recorder";

export async function POST(req: NextRequest) {
  const user = await getCurrentUser();
  const { title, assigneeId, projectId } = await req.json();

  const task = await db.task.create({
    data: { title, assigneeId, projectId, creatorId: user.id },
  });

  // Record activity β€” fire and forget (don't block response)
  recordActivity({
    teamId: user.teamId,
    actorId: user.id,
    verb: "created",
    objectType: "task",
    objectId: task.id,
    objectTitle: task.title,
    targetUrl: `/projects/${projectId}/tasks/${task.id}`,
    // Notify the assignee if different from creator
    notifyUserIds: assigneeId && assigneeId !== user.id ? [assigneeId] : [],
    notificationType: "assigned",
    metadata: { projectId, projectTitle: task.project?.name },
  }).catch(console.error);

  return NextResponse.json(task, { status: 201 });
}

πŸ’‘ The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments β€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity β€” you own everything

Feed Query Service

// lib/activity/feed.ts
import { db } from "@/lib/db";

interface FeedOptions {
  teamId: string;
  cursor?: string;
  limit?: number;
  objectType?: string;
  actorId?: string;
}

export async function getTeamActivityFeed({
  teamId,
  cursor,
  limit = 25,
  objectType,
  actorId,
}: FeedOptions) {
  const where: any = { teamId };

  if (cursor) {
    where.createdAt = { lt: new Date(cursor) };
  }
  if (objectType) {
    where.objectType = objectType;
  }
  if (actorId) {
    where.actorId = actorId;
  }

  const activities = await db.activity.findMany({
    where,
    orderBy: { createdAt: "desc" },
    take: limit + 1, // Fetch one extra to check hasMore
    include: {
      actor: {
        select: { id: true, name: true, avatarUrl: true },
      },
    },
  });

  const hasMore = activities.length > limit;
  const items = hasMore ? activities.slice(0, limit) : activities;
  const nextCursor = hasMore ? items[items.length - 1].createdAt.toISOString() : null;

  return { items, nextCursor, hasMore };
}

// User-specific notification feed
export async function getUserNotificationFeed(
  userId: string,
  options: { cursor?: string; limit?: number; unreadOnly?: boolean } = {}
) {
  const { cursor, limit = 20, unreadOnly = false } = options;

  const where: any = {
    userId,
    isDismissed: false,
  };

  if (unreadOnly) where.isRead = false;
  if (cursor) where.createdAt = { lt: new Date(cursor) };

  const notifications = await db.userNotification.findMany({
    where,
    orderBy: { createdAt: "desc" },
    take: limit + 1,
    include: {
      activity: {
        include: {
          actor: { select: { id: true, name: true, avatarUrl: true } },
        },
      },
    },
  });

  const unreadCount = await db.userNotification.count({
    where: { userId, isRead: false, isDismissed: false },
  });

  const hasMore = notifications.length > limit;
  const items = hasMore ? notifications.slice(0, limit) : notifications;

  return {
    items,
    hasMore,
    nextCursor: hasMore ? items[items.length - 1].createdAt.toISOString() : null,
    unreadCount,
  };
}

Real-Time Updates via SSE

// app/api/feed/stream/route.ts
import { NextRequest } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { sseManager } from "@/lib/sse/manager";

export async function GET(req: NextRequest) {
  const user = await getCurrentUser();
  if (!user) return new Response("Unauthorized", { status: 401 });

  const stream = new ReadableStream({
    start(controller) {
      // Register this connection
      const cleanup = sseManager.subscribe(user.id, (event) => {
        const data = `data: ${JSON.stringify(event)}\n\n`;
        controller.enqueue(new TextEncoder().encode(data));
      });

      // Heartbeat to keep connection alive
      const heartbeat = setInterval(() => {
        try {
          controller.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
        } catch {
          clearInterval(heartbeat);
        }
      }, 30_000);

      // Cleanup on disconnect
      req.signal.addEventListener("abort", () => {
        cleanup();
        clearInterval(heartbeat);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no", // Disable nginx buffering
    },
  });
}
// lib/sse/manager.ts β€” simple in-process pub/sub
// For multi-server: replace with Redis pub/sub (see post-319)

type Subscriber = (event: unknown) => void;

class SSEManager {
  private subscribers = new Map<string, Set<Subscriber>>();

  subscribe(userId: string, callback: Subscriber): () => void {
    if (!this.subscribers.has(userId)) {
      this.subscribers.set(userId, new Set());
    }
    this.subscribers.get(userId)!.add(callback);

    return () => {
      this.subscribers.get(userId)?.delete(callback);
      if (this.subscribers.get(userId)?.size === 0) {
        this.subscribers.delete(userId);
      }
    };
  }

  send(userId: string, event: unknown) {
    this.subscribers.get(userId)?.forEach((cb) => {
      try { cb(event); } catch { /* subscriber disconnected */ }
    });
  }
}

export const sseManager = new SSEManager();

Activity Feed UI

// components/ActivityFeed/ActivityFeed.tsx
"use client";

import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { formatDistanceToNow } from "date-fns";
import Image from "next/image";
import { Loader2 } from "lucide-react";

function ActivityItem({ activity }: { activity: any }) {
  const verb = {
    created: "created",
    updated: "updated",
    deleted: "deleted",
    commented: "commented on",
    invited: "invited someone to",
    assigned: "was assigned",
    completed: "completed",
  }[activity.verb as string] ?? activity.verb;

  return (
    <div className="flex gap-3 py-3 border-b last:border-0">
      <div className="flex-shrink-0">
        {activity.actor.avatarUrl ? (
          <Image
            src={activity.actor.avatarUrl}
            alt={activity.actor.name}
            width={32}
            height={32}
            className="rounded-full"
          />
        ) : (
          <div className="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 text-sm font-medium">
            {activity.actor.name[0]}
          </div>
        )}
      </div>
      <div className="flex-1 min-w-0">
        <p className="text-sm">
          <span className="font-medium">{activity.actor.name}</span>
          {" "}{verb}{" "}
          {activity.targetUrl ? (
            <a
              href={activity.targetUrl}
              className="font-medium text-blue-600 hover:underline"
            >
              {activity.objectTitle ?? activity.objectType}
            </a>
          ) : (
            <span className="font-medium">
              {activity.objectTitle ?? activity.objectType}
            </span>
          )}
        </p>
        <p className="text-xs text-gray-400 mt-0.5">
          {formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
        </p>
      </div>
    </div>
  );
}

export function ActivityFeed({ teamId }: { teamId: string }) {
  const queryClient = useQueryClient();
  const eventSourceRef = useRef<EventSource | null>(null);

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
    useInfiniteQuery({
      queryKey: ["activity-feed", teamId],
      queryFn: ({ pageParam }) => {
        const params = new URLSearchParams({ limit: "25" });
        if (pageParam) params.set("cursor", pageParam as string);
        return fetch(`/api/feed?teamId=${teamId}&${params}`).then((r) => r.json());
      },
      initialPageParam: undefined as string | undefined,
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    });

  // Subscribe to real-time updates
  useEffect(() => {
    const es = new EventSource("/api/feed/stream");
    eventSourceRef.current = es;

    es.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.type === "notification") {
        // Prepend new activity to the top of the feed
        queryClient.invalidateQueries({ queryKey: ["activity-feed", teamId] });
      }
    };

    return () => {
      es.close();
      eventSourceRef.current = null;
    };
  }, [teamId, queryClient]);

  const allItems = data?.pages.flatMap((p) => p.items) ?? [];

  if (isLoading) {
    return (
      <div className="flex justify-center py-8">
        <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
      </div>
    );
  }

  return (
    <div>
      {allItems.map((activity) => (
        <ActivityItem key={activity.id} activity={activity} />
      ))}

      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
          className="w-full py-3 text-sm text-blue-600 hover:text-blue-700 disabled:opacity-50"
        >
          {isFetchingNextPage ? "Loading..." : "Load more"}
        </button>
      )}

      {!hasNextPage && allItems.length > 0 && (
        <p className="text-center text-xs text-gray-400 py-4">
          You're all caught up!
        </p>
      )}
    </div>
  );
}

Notification Batching

Prevent notification spam (e.g., 50 comments in quick succession β†’ one digest):

// lib/activity/batch-notifications.ts
import { db } from "@/lib/db";

/**
 * Batch notifications of the same type within a window.
 * Instead of 50 "commented" notifications, send one "50 comments on X".
 */
export async function getBatchedNotifications(
  userId: string,
  windowMinutes: number = 60
): Promise<BatchedNotification[]> {
  const since = new Date(Date.now() - windowMinutes * 60 * 1000);

  // Group unread notifications by (object_type, object_id, notification_type)
  const grouped = await db.$queryRaw<Array<{
    notification_type: string;
    object_type: string;
    object_id: string;
    object_title: string;
    count: bigint;
    latest_at: Date;
    actor_names: string;
  }>>`
    SELECT
      n.notification_type,
      a.object_type,
      a.object_id,
      a.object_title,
      COUNT(*) as count,
      MAX(n.created_at) as latest_at,
      STRING_AGG(DISTINCT u.name, ', ' ORDER BY u.name LIMIT 3) as actor_names
    FROM user_notifications n
    JOIN activities a ON a.id = n.activity_id
    JOIN users u ON u.id = a.actor_id
    WHERE n.user_id = ${userId}
      AND n.is_read = FALSE
      AND n.is_dismissed = FALSE
      AND n.created_at > ${since}
    GROUP BY n.notification_type, a.object_type, a.object_id, a.object_title
    ORDER BY latest_at DESC
    LIMIT 20
  `;

  return grouped.map((g) => ({
    type: g.notification_type,
    objectType: g.object_type,
    objectId: g.object_id,
    objectTitle: g.object_title,
    count: Number(g.count),
    latestAt: g.latest_at,
    actorNames: g.actor_names,
    summary: buildNotificationSummary(g),
  }));
}

function buildNotificationSummary(g: any): string {
  const actors = g.actor_names;
  const count = Number(g.count);
  const obj = g.object_title ?? g.object_type;

  if (count === 1) {
    return `${actors} ${g.notification_type} ${obj}`;
  }
  return `${actors} and ${count - 1} others ${g.notification_type} ${obj}`;
}

Cost and Timeline Estimates

ComponentTimelineCost (USD)
Activity schema + recorder1–2 days$800–$1,600
Fanout service + notifications1–2 days$800–$1,600
Feed query API + cursor pagination1 day$600–$1,000
SSE real-time updates1–2 days$800–$1,600
Activity feed UI with infinite scroll2–3 days$1,600–$2,500
Full activity + notification system2 weeks$6,000–$10,000

See Also


Working With Viprasol

We build activity feed and notification infrastructure for B2B SaaS productsβ€”from simple team timelines through real-time collaborative feeds at scale. Our team has shipped feed systems for SaaS products with 10,000+ daily active users.

What we deliver:

  • Activity recording integrated into your existing domain events
  • Fanout architecture matched to your team size and write volume
  • Real-time delivery via SSE or WebSocket
  • Notification batching and digest emails
  • Feed filtering and search

Explore our SaaS development services or contact us to add activity feeds to your product.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours β€” fast.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow β€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.