Activity feeds look like the simplest feature in your app. A reverse-chronological list of stuff that happened. Sort by timestamp, render, done. That was then. Feeds are now a composite of the underlying event and everything that accumulates on top of it: likes, threads, GIFs, replies, and read receipts.
If you're building one today, you need the whole experience. Something that scales to tens of thousands of concurrent users, operates close to real time, and never drops a message.
This article walks through that: what each layer does, the right tradeoffs at each one, and what you can offload to a feeds API.
How Activity Feeds Work, End-to-End
Before we get into code, here's the shape of the system we're building.
Your application emits activities when things happen: a task gets created, a comment lands, someone reacts. Each of those becomes a record that goes into a store.
The store is the source of truth for the feed. From the store, there are two paths to the user.
- The publish path pushes new activity to clients while they're looking, usually over WebSocket or SSE on top of a pub/sub bus.
- The query path runs when the user opens the inbox and pulls the feed on demand. Both paths run through the same processing: filter by audience and view, then aggregate.
The activity record captures what happened in the world. Read state lives in its own store, separate from the activity record, and captures a particular user's relationship to those events. They have different lifecycles and update independently, so keeping them in separate stores avoids coupling problems later.
Three things worth pulling out of the diagram:
- Activities are the source of truth. Everything downstream (rendered feed, real-time pushes, unread counts) is derived from the activity log.
- Two paths, same processing. The real-time push and the on-demand query both flow through filter and aggregate.
- Read state is separate. It's keyed by feed item, not activity.
With that, let’s start building.
What Goes in the Feed?
Before any infrastructure, decide what an activity is.
The standard model, popularized by the Activity Streams spec, is actor-verb-object, optionally with a target. A user (actor) did a thing (verb) to something (object) in some context (target). It's the same shape that powers GitHub, Asana, and Stream's Feeds product.
A JSON of the model would look like this:
123456789{ id: 'act_1842', timestamp: Date, actor: { id: 'sarah' }, verb: 'commented', object: { type: 'task', id: 'OB-87', name: 'Onboarding empty states v2' }, target: { type: 'project', id: 'onboarding', name: 'Q1 Onboarding Revamp' }, payload: { preview: 'Looks good to me, shipping it.' } }
A few things worth calling out:
- Object and target are typed. This lets one feed render activities about tasks, docs, projects, and PRs without the rendering layer needing to look anything up.
- Payload holds verb-specific data. Comments have a preview, reactions have an emoji, and status changes have from/to. The activity record stays self-contained instead of requiring a join when you read it.
- No display formatting in the activity. The record says what happened, not how to render it. "Sarah commented on Onboarding empty states v2" is computed at read time.
That last point matters more than it sounds. If you embed display strings in your activities, you're locked in to that display. If you want to localize the app later, change the wording, or support a different language, you'll need much more heavy lifting than if you keep the record display-agnostic.
Layer 1: the activity store
We have to start with the activity store. This is an append-only log: activities go in, never get modified, and can be queried in order.
The most common shape is a Postgres table with a btree index on (audience_id, timestamp), where audience_id scopes the activity (a workspace, a feed name, a follower set). DynamoDB works too, with a composite key. The right choice depends on your read pattern, which we'll get to in the fan-out section.
A reasonable starting point in Postgres:
create table activities (
id uuid primary key default gen_random_uuid(),
audience_id text not null,
occurred_at timestamptz not null default now(),
actor_id text not null,
verb text not null,
object_type text not null,
object_id text not null,
object_name text, -- denormalized for fast reads
target_type text,
target_id text,
target_name text, -- denormalized for fast reads
payload jsonb not null default '{}'::jsonb
);
-- Primary read: latest activity in a given feed
create index activities_audience_time_idx
on activities (audience_id, occurred_at desc);
-- Secondary: everything an actor did
create index activities_actor_time_idx
on activities (actor_id, occurred_at desc);
-- Secondary: all activity on a given object (per-task views, audit trails)
create index activities_object_time_idx
on activities (object_type, object_id, occurred_at desc);
A few things this is doing:
- audience_id scopes the feed. In production, almost every read filters by audience: a workspace ID in a team app, the owner's user ID for a timeline in a social product. That's why it leads the primary index.
- Object and target names are denormalized. Storing them on the activity row means feed reads don't need joins against tasks, projects, or docs to render. The cost is that renames have to fan out to historical activities, or you accept that old rows show old names. Most feeds accept it.
- payload is jsonb. Verb-specific fields like comment preview, reaction emoji, and status from/to live there, so adding a new verb doesn't require a migration.
- Three indices for three read patterns. Audience plus time powers the inbox. Actor plus time powers "things Sarah did." Object plus time powers per-object activity views and audit trails.
Past a few million rows, you'll want to partition the table by occurred_at, usually monthly. Past a few billion, you're either sharding by audience_id or moving to a system designed for high-write-throughput log storage.
This is where build-vs-buy gets real. A feeds backend isn't only storage. It's storage plus pub/sub plus a real-time transport, all of which have to stay consistent.
Layer 2: The Aggregator
When five people react to the same document, you don't want five rows. You want “5 people reacted to Updated pricing page copy.” Same for comments, follows, joins, edits, and anything else that bursts.
Each aggregating verb needs three things:
- A group key that decides which activities belong together.
- A time window that decides how long a group stays open.
- A minimum count below which the group falls back to individual items.
Here's a rule set:
123456789101112export const AGGREGATION_RULES = { reacted: { groupBy: (a) => `reacted:${a.object.type}:${a.object.id}`, window: 1 * DAY, minCount: 2, }, commented: { groupBy: (a) => `commented:${a.object.type}:${a.object.id}`, window: 4 * HOUR, minCount: 2, }, };
Having this as a simple configuration means that if you want to add a new aggregating verb, it is a config change, not a code change. Like with the activity model, you want to future-proof your build. A simple config change here means you can say yes more easily when the product team wants a new aggregation rule.
On that, the window and minCount values are product decisions, not technical ones. A 1-day window for reactions is forgiving: a Monday reaction and a Tuesday reaction will merge. A 4-hour window for comments is tighter: a thread that goes quiet for half a day starts a fresh row when it picks back up. There's no universal right answer. Pick what reads well for your product and tune it.
The aggregator itself is a pure function:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748export function aggregateActivities(activities) { const sorted = [...activities].sort((a, b) => b.timestamp - a.timestamp); const items = []; const openGroups = new Map(); for (const activity of sorted) { const rule = AGGREGATION_RULES[activity.verb]; if (!rule) { items.push({ kind: 'single', id: `f_${activity.id}`, activity, timestamp: activity.timestamp }); continue; } const key = rule.groupBy(activity); const open = openGroups.get(key); if (open && (open.latest - activity.timestamp) <= rule.window) { open.item.activities.push(activity); if (!open.item.actors.includes(activity.actor.id)) { open.item.actors.push(activity.actor.id); } } else { const item = { kind: 'aggregate', id: `f_${key}_${activity.timestamp.getTime()}`, verb: activity.verb, object: activity.object, target: activity.target, activities: [activity], actors: [activity.actor.id], timestamp: activity.timestamp, }; openGroups.set(key, { item, latest: activity.timestamp }); items.push(item); } } // Demote aggregates that didn't reach minCount return items.map((item) => { if (item.kind !== 'aggregate') return item; const rule = AGGREGATION_RULES[item.verb]; if (item.activities.length < rule.minCount) { const a = item.activities[0]; return { kind: 'single', id: `f_${a.id}`, activity: a, timestamp: a.timestamp }; } return item; }); }
We sort by newest first, then for each activity, we check whether it has an aggregation rule. No rule means it's a single item. If there is a rule, we compute the group key. If an open group with that key exists and its newest activity falls within the window, we attach. Otherwise, we start a new group.
The final pass demotes any groups that didn't reach the minimum. One person reacting to a doc isn't an aggregate. It's "Sarah reacted." The same record is just rendered differently.
This is doing read-time aggregation. Activities are stored raw, and aggregation happens when you fetch the feed. That works for a moderate scale. For high-scale systems, you'd precompute aggregated feeds at write time, which means the aggregator runs as part of the fan-out path and writes feed items to per-user storage. Faster on read, but more complex to maintain and harder to change rules retroactively because old activities are already grouped.
Layer 3: read state
Read state is the only part of the system that's truly per-user. Activities are shared facts about what happened in the world. Read state is one user's view of those facts: what they've seen vs. what's still new.
Read state is write-often: every inbox view generates writes for whichever rows the user just saw. So the storage you'd happily use for activities (one big append-only table) is a poor fit for read state, which needs efficient per-user updates.
However, the interface is small:
123456789101112131415161718192021222324export function createReadState(seedRead = new Set()) { let read = new Set(seedRead); const subscribers = new Set(); const notify = () => subscribers.forEach((cb) => cb()); return { isRead: (id) => read.has(id), markRead(id) { read.add(id); notify(); }, markAllRead(ids) { ids.forEach((id) => read.add(id)); notify(); }, unreadCount(items) { return items.filter((it) => !read.has(it.id)).length; }, subscribe(cb) { subscribers.add(cb); return () => subscribers.delete(cb); }, }; }
A Set of read IDs with a pub/sub layer on top. markRead adds, isRead checks, unreadCount filters a list of feed items against the set, and subscribe triggers a rerender of the UI on changes.
The interesting work is in the storage shape and the coordination. Three things to think through:
- Per-item flags vs seen cursor. Per-item flags (
user_id,feed_item_id) are granular but writes scale with views. Seen-up-to cursors store one timestamp per user per feed, cheap but unable to represent a read row above an unread one. Most products end up hybrid: a cursor for the unread badge and per-item flags for the inbox UI. - Cross-device sync. The read state has to be authoritative on the server. Writes go to the server immediately, and the server pushes read events to other connected clients on the same account so the badge matches across devices.
- Cleanup. Read flags pile up forever unless you garbage-collect. The simplest policy is to drop them when the underlying activity passes the feed's retention window, or set a TTL on the read store and let them expire.
Read state is the hot path. The point of an activity feed is to read it. Every page view, every notification check, every badge refresh hits it. Activities are read-heavy, too, but they're cacheable; read state is, by definition, uncacheable across users.
Layer 4: filtering and feed views
A feed isn't one view. A user has multiple ways to slice the same activity stream:
- Everything in their workspace
- Just mentions
- Just things they're following
- Just notifications
You allow this slicing by filtering raw activities before aggregation:
123456789101112131415161718const filteredActivities = useMemo(() => { return activities.filter((a) => { if (filter === 'mentions') { if (!(a.verb === 'mentioned' && a.payload?.mentionedUser === 'you')) return false; } if (filter === 'following') { const followed = ['priya', 'marcus', 'sarah']; if (!followed.includes(a.actor.id)) return false; } if (projectFilter !== 'all') { const projId = a.target?.id || (a.object?.type === 'project' ? a.object.id : null); if (projId !== projectFilter) return false; } return true; }); }, [activities, filter, projectFilter]); const feedItems = useMemo(() => aggregateActivities(filteredActivities), [filteredActivities]);
Filter first, then aggregate. If you aggregate first and filter after, you can end up with broken aggregates: “3 people commented,” but only 1 of them passed the filter.
This is also why aggregation has to be cheap. We re-run it on every filter change. At scale, you don't do this in the browser. You do it server-side with cached intermediate results, or you precompute multiple feed groups (one for "all," one for "mentions," one for each filter the user can apply) and pick the right one at read time.
Layer 5: real-time updates
The store has a subscribe method that fires whenever an activity is added. The React layer subscribes and rerenders:
12345export function useActivities(store) { const [activities, setActivities] = useState(() => store.getAll()); useEffect(() => store.subscribe(() => setActivities([...store.getAll()])), [store]); return activities; }
In production, your task service emits an activity when a task is created. Your comment service emits when a comment lands. The activities flow into the store, and from there to every connected client.
The transport is the hard part. Three options, in increasing order of complexity and quality:
- Polling. Client asks for new activities every N seconds. Simple, works everywhere, wastes bandwidth, and has latency.
- Server-Sent Events (SSE). One-way push from server to client over an open HTTP connection. Cheap, works through most proxies, no bidirectional needs.
- WebSocket. Bidirectional persistent connection. Most flexible, hardest to operate.
For activity feeds specifically, SSE is usually enough. The client doesn't need to push anything back; it just needs to receive new activities consistently. The complications are connection management (reconnecting on network blips, deduplicating after reconnect, handling stale tabs) and fan-out (every connected client needs to be subscribed to a topic for every audience they belong to).
Layer 6: the UI
Though the “work” is done upstream, neglecting the UI layer can mean all that work is for naught. If a user is confused by the activity feed UI, they won’t use it.
To increase visual scannability, you need to render by activity type. The simple answer is to switch over verbs that produce the JSX:
1234567891011121314151617case 'created': return <span>{actorTag} created <Token>{a.object.name}</Token> in <ProjectTag target={a.target} /></span>; case 'completed': return <span>{actorTag} completed <Token>{a.object.name}</Token> in <ProjectTag target={a.target} /></span>; case 'assigned': { const assignee = USERS[a.payload.assignee]; return ( <span> {actorTag} assigned <Token>{a.object.name}</Token> to{' '} <span className="inline-flex items-center gap-1"> <Avatar userId={a.payload.assignee} size={16} /> <span className="text-[#1C1917] font-medium">{assignee.name}</span> </span> </span> ); } // ...
That switch grows as you add verbs. Resist the urge to put rendering logic in the activity itself. The activity is data; rendering is policy. They change for different reasons.
For aggregates, rendering also has to handle the aggregate-specific layout: a stack of avatars instead of a single avatar, “5 people” instead of a name, an emoji tally instead of a single reaction. Same data, different shape:
12345678910111213141516if (item.kind === 'aggregate' && item.verb === 'reacted') { const tally = {}; item.activities.forEach((a) => { tally[a.payload.reaction] = (tally[a.payload.reaction] || 0) + 1; }); return ( <div className="mt-2 flex items-center gap-1.5 flex-wrap"> {Object.entries(tally).map(([emoji, count]) => ( <span key={emoji} /* ... */> <span>{emoji}</span> <span className="tabular-nums">{count}</span> </span> ))} </div> ); }
Aggregates carry the underlying activities with them, so the UI can compute things like the reaction tally without going back to the store. This is one of the reasons aggregates are records, not just summaries.
What we left out
This implementation is small enough to read in one sitting. A production version has to handle several things we skipped.
- Audience and permissions. Who's allowed to see each activity? In a single-tenant team app, it might be "everyone in the workspace." In a social product, it's a function of follows, blocks, privacy settings, and content moderation. Compute it at write time (fan out to allowed audiences) or at read time (filter on every fetch). Both have tradeoffs.
- Durability and ordering. In-memory pub/sub loses messages on restart. You need durable storage, at-least-once delivery, and idempotency on the client side, because at-least-once delivery can lead to duplicates.
- Backfill. When a user joins a workspace or follows someone new, do they see existing activity? If yes, you need a backfill process that copies old activities into their feed. If not, the feed starts empty, leaving them without context. Both are valid product decisions, but the implementations differ significantly.
- Ranking. Reverse-chronological is the default but not the only option. Some products rank by recency-weighted relevance, by closeness to the actor, or by topic affinity. Ranking adds a sort step on top of aggregation and changes the storage shape: you now need a score column or a per-edge weight.
- Scale and fan-out. At a small scale, read-time aggregation with a single Postgres table is fine. Once you're past a few thousand activities per user, you want write-side fan-out: when an activity is created, write it to every viewer's feed up front. This makes reads O(1) instead of O(activity table), but writes become O(viewers). The trade-off is acceptable in most products because reads vastly outnumber writes, but the operational complexity increases.
- Compaction and retention. Activities pile up. Most products cap how far back the feed goes (30, 60, 90 days) and either delete or archive older records. The cap is also where you implement aggregation across long time periods, since "5 people reacted" stops being useful when the reactions are spread across six months.
Most of these aren't where your product differentiates. The activity model, the aggregation rules, the filter-then-aggregate ordering, and the read-state design: those are decisions you make once and live with. The plumbing underneath is undifferentiated, which is why a hosted feeds API exists as an option.
Stream Feeds handles the plumbing:
- Activity storage with a SQL-like API for adding and querying.
- Aggregated, flat, and notification feed groups configured per group, no aggregator code to write.
- Real-time updates over WebSocket, with reconnection handled.
- Follow relationships with built-in fan-out and backfill.
- Read/seen state as a first-class API on notification feeds.
- Ranking when you need it, configured per feed group.
You still own the activity model (what verbs exist, what payloads carry), the rendering layer, and the product decisions about what gets aggregated and when. The infrastructure underneath is rented.
We opened with three things you need to build a production-grade activity feed: understand each layer, make the right tradeoffs at each one, and know what you can offload. The layers are above. The tradeoffs are flagged inline. The offload question is the one decision that varies by team.
Build the whole thing if activity feeds are core to your product, or if you're operating at a scale where managed costs hurt. Rent the plumbing otherwise, and put your time into the parts only your team can build: the activity model, the aggregation choices, the rendering.
For activity feeds, "otherwise" covers most teams.
