Back to BlogArchitecture & Engineering

Building an Activity Feed Users Trust

Rupak Amin

Founder & Lead Engineer, RAITHub

7 min read

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 feedAudit log
AudienceEnd usersAdmins, auditors, security
GoalReadable, relevant, filteredComplete, tamper-evident, retained
Can omit events?Yes, for noise or permissionsNo; it records everything
Can be edited or pruned?Old entries can age outAppend-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?

OptionExamplesChoose this whenWatch out for
Feeds-as-a-serviceHosted activity-feed and timeline APIsYou want ranked, personalised feeds at social-network scale fastYour activity data lives in their system; permission filtering against your rules can be awkward
Analytics event pipelineA product-analytics event storeYou mainly want events for analysis, not a user-facing timelineNot built to render a filtered per-user feed with your permissions
Build it inThe append-only stream aboveYou want one event stream feeding feed, notifications and audit, in your own dataYou own grouping, permission filtering and paging
Hire a team to build itRAITHub or another studioFeed, notifications and audit should share one event model and agreeInsist 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.

Activity feedAudit timelineEvent streamAppend-onlySaaSPostgreSQL

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.