Founder & Lead Engineer, RAITHub
RAITHub ships and tests production software. See QA as a Service or talk to us.
An activity feed is the human-readable timeline of what happened in an account: who did what, and when. Build it on one append-only event stream, written in the same transaction as the change, rendered per audience with the viewer's permissions applied, grouped to cut noise, and paged by cursor. It overlaps with the audit log, but a feed is for users and an audit log is for compliance.
If you would rather have an activity feed built for you, see how RAITHub would build this below.
What is an activity feed, and how is it different from an audit log?
Both are records of events, but they serve different readers and so have different rules. An activity feed is a product feature: a readable "Maria closed the task" timeline users scan to stay in sync. An audit log is a compliance and security record: complete, tamper-evident, and kept whether or not anyone reads it.
| Activity feed | Audit log | |
|---|---|---|
| Audience | End users | Admins, auditors, security |
| Goal | Readable, relevant, filtered | Complete, tamper-evident, retained |
| Can omit events? | Yes, for noise or permissions | No; it records everything |
| Can be edited or pruned? | Old entries can age out | Append-only, often hash-chained |
Many products build both from one event stream, then render each differently. The compliance side, including hash-chaining and retention, is in SaaS audit log design. This post is the user-facing feed.
How should activity events be stored?
As one append-only table of events, each with an actor, a verb, a target and the data a renderer needs, scoped to the tenant. Write the event in the same transaction as the change, so a committed change always has its event and a rolled-back one never does.
CREATE TABLE activity_events (
id bigserial PRIMARY KEY,
workspace_id uuid NOT NULL,
actor_id uuid, -- NULL for system actions
verb text NOT NULL, -- 'task.closed', 'member.invited'
target_type text NOT NULL, -- 'task', 'invoice'
target_id uuid NOT NULL,
data jsonb NOT NULL, -- snapshot the renderer needs
created_at timestamptz NOT NULL DEFAULT now()
);
-- Feed query: newest first, within a workspace.
CREATE INDEX ON activity_events (workspace_id, id DESC);
Store a small snapshot of what the line needs to render (the task title at the time, the member's name) in data, so the feed still reads correctly after the task is renamed or the member leaves. Resolving everything live at read time makes old lines wrong or broken.
How do you render the feed per audience, with permissions?
The same event stream produces different feeds for different viewers. Never show a user a line about a resource they are not allowed to see.
// Filter events to what this viewer may see. The feed respects the same
// permission checks as the rest of the app; it is not a back door.
export async function feedFor(db: Tx, viewerId: string, workspaceId: string, cursor?: string) {
const rows = await db.query(
'SELECT * FROM activity_events ' +
'WHERE workspace_id = $1 AND ($2::bigint IS NULL OR id < $2) ' +
'ORDER BY id DESC LIMIT 50',
[workspaceId, cursor ?? null],
)
// Drop events whose target the viewer cannot access, using the same can() check as everywhere else.
return filterByPermission(viewerId, rows)
}
Use the same permission check the rest of your app uses, not a feed-specific shortcut; the check is covered in building a roles and permissions UI. Page by cursor (the last id), not by offset, so the feed stays fast and does not skip or repeat rows as new events arrive.
How do you keep the feed readable and not noisy?
A raw event stream is unreadable. Group, summarise and filter so each line earns its place.
- Group bursts. "Alex made 8 edits to the proposal" instead of eight lines. Group by actor, target and a time window.
- Filter by relevance. Let users filter by person, type or the items they follow, so a busy account is still useful.
- Humanise verbs. Map machine verbs ("task.closed") to readable sentences with the actor's name and a link to the target.
- Collapse low-value events. Not everything deserves a line in the feed; keep those only in the audit log.
A feed also drives notifications: the same event can create an in-app message, through the layer in building a SaaS notification system. One event stream, several outputs.
Buy, build or hire?
| Option | Examples | Choose this when | Watch out for |
|---|---|---|---|
| Feeds-as-a-service | Hosted activity-feed and timeline APIs | You want ranked, personalised feeds at social-network scale fast | Your activity data lives in their system; permission filtering against your rules can be awkward |
| Analytics event pipeline | A product-analytics event store | You mainly want events for analysis, not a user-facing timeline | Not built to render a filtered per-user feed with your permissions |
| Build it in | The append-only stream above | You want one event stream feeding feed, notifications and audit, in your own data | You own grouping, permission filtering and paging |
| Hire a team to build it | RAITHub or another studio | Feed, notifications and audit should share one event model and agree | Insist the handover proves the feed never leaks events past permissions |
Do-it-yourself estimate: 4–7 days for the event stream, in-transaction writes, a permission-filtered cursor-paged feed, grouping and readable rendering, if a permission check exists. The main risk is a feed that shows a user a line about something they are not allowed to see, which is a quiet data leak.
How RAITHub would build this
As part of a new SaaS build, or added to an existing product, scoped in writing after the free audit.
- One event stream: append-only, written in the same transaction as the change, with a render snapshot per event.
- Permission-filtered feeds: rendered per viewer using the same checks as the rest of the app, paged by cursor.
- Readable output: grouped bursts, humanised verbs, and filters by person, type and followed items.
- Shared with notifications and audit, so one event can drive the feed, a notification and the compliance log.
Timeline: inside a new product, this is part of the 4–6 week fixed-scope SaaS build; added to an existing backend, it fits the 6–12 week backend range, with the exact scope in the quote.
You receive: automated tests and CI, including tests that the feed respects permissions, handover docs, and full IP assigned to you under NDA.
Next step: a free 15-minute technical audit, then a written fixed quote. See the SaaS development service, or book the audit.
Frequently asked questions
Is an activity feed the same as an audit log?
No. A feed is a user-facing product feature that can omit and group events for readability. An audit log is a complete, tamper-evident compliance record. Many products build both from one event stream but render them under different rules.
When should I write the activity event?
In the same database transaction as the change itself. Then a committed change always has its event and a rolled-back change never leaves a phantom one, so the feed and the data can never disagree.
How do I stop the feed leaking things a user should not see?
Apply the same permission check the rest of your app uses when rendering the feed, dropping events whose target the viewer cannot access. A feed must not become a back door that reveals resources the main UI hides.
How do I keep a busy account's feed readable?
Group bursts of events into one line, humanise the verbs with names and links, let users filter by person or type, and keep low-value events out of the feed and only in the audit log.
Should I page the feed by offset or cursor?
By cursor, using the last event id seen. Offset paging gets slow on large tables and can skip or repeat rows as new events arrive at the top. A cursor keeps paging fast and stable.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.