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.
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
| Component | Timeline | Cost (USD) |
|---|---|---|
| Activity schema + recorder | 1β2 days | $800β$1,600 |
| Fanout service + notifications | 1β2 days | $800β$1,600 |
| Feed query API + cursor pagination | 1 day | $600β$1,000 |
| SSE real-time updates | 1β2 days | $800β$1,600 |
| Activity feed UI with infinite scroll | 2β3 days | $1,600β$2,500 |
| Full activity + notification system | 2 weeks | $6,000β$10,000 |
See Also
- SaaS Audit Logging β Compliance-grade event logging (different from activity feeds)
- React Virtualized Lists β Virtualizing large activity feeds
- PostgreSQL Full-Text Search β Searching the activity feed
- AWS SQS Worker Pattern β Async fanout for high-volume products
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.
About the Author
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.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours β fast.
Free consultation β’ No commitment β’ Response within 24 hours
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.