Designing an Activity Feed for B2B SaaS: Events, Aggregation, and Privacy-Safe Logging

#activity feed SaaS
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

An activity feed sounds simple until you actually build one. The naive approach - log every event, display them in reverse-chronological order - works fine in staging. Then production traffic hits and you end up with a wall of noise: "Alice updated field X," "Alice updated field Y," "Alice updated field Z" - three entries for a two-second form save. Users stop reading it. Support tickets start arriving about "confusing" audit logs. And somewhere down the line, a security review flags that you are storing more personal data than you declared in your privacy policy.

A well-designed activity feed for SaaS serves three audiences at once: end users who want a readable record of what happened, operations teams who need a reliable audit trail, and legal/compliance who need to know exactly what data you collect and why. Getting those three to agree on one data structure is most of the design work.

What Counts as an Event

Before you write a single line of code, define your event taxonomy. Events fall into roughly three categories:

User-initiated actions - things a person explicitly did: created a project, invited a teammate, changed a plan, deleted a resource. These are high-signal and almost always worth logging.

System-triggered actions - things your application did automatically: sent a scheduled report, expired an API token, applied a rule-based label. Worth logging, but often safe to suppress in the user-facing feed while keeping them in the internal audit trail.

State-change events - a field or record moved from one value to another. These are the noisy ones. A user updating a contact record might produce dozens of field-level change events in under a second. They matter for compliance but they drown a feed.

Separating these three categories early makes every downstream decision easier: what to show, what to aggregate, what to store, and what to redact.

The Aggregation Problem

The most common complaint about activity feeds is that they are too noisy. The fix is event aggregation - collapsing related events into a single readable entry.

There are two main aggregation strategies:

Time-window aggregation groups events from the same actor and the same resource within a short window (typically 30 seconds to 5 minutes). "Alice updated contact Acme Corp 14 times" becomes "Alice updated contact Acme Corp." This works well for high-frequency edits and is straightforward to implement with a background job or a sliding window in Redis.

Semantic aggregation groups events by logical meaning regardless of timing. If a user adds three tags, removes two permissions, and updates a description - all as part of a single intent - semantic aggregation collapses that into "Alice modified project settings." This requires more domain knowledge but produces a much more readable feed.

In practice most teams start with time-window aggregation and layer in semantic rules for known high-frequency patterns. The important thing is that aggregation happens before storage, not at query time - computing aggregates on read does not scale.

A pattern worth borrowing from event sourcing: store the raw events in an append-only log (your audit trail), and write a separate materialized view of aggregated entries for the feed. The raw log is your source of truth. The feed is a projection. This separation lets you change aggregation logic without losing history.

Audit Trail vs. User-Facing Feed

These are not the same thing, and conflating them causes problems.

The audit trail is for compliance and operations. It needs to be: complete (no selective omission), tamper-evident (ideally append-only with checksums), retention-bounded (you need to be able to delete it per GDPR right-to-erasure requests), and machine-readable. Access is typically restricted to admins and security tools. Nobody is browsing it casually.

The user-facing feed is for end users. It needs to be: readable, relevant, fast to load, and free of internal noise. It can and should omit low-signal system events. It should use natural language descriptions, not raw event type strings.

The cleanest architecture keeps these in separate tables or collections. The audit log is append-only with no soft deletes. The feed is a denormalized projection you can rebuild from the audit log at any time. When a user exercises their right to erasure, you pseudonymize their identifier in both stores rather than deleting rows (which would break audit trail integrity).

Privacy-Safe Logging

GDPR and similar regulations treat activity logs as personal data. That has concrete technical implications.

Minimize what you log. If your feed says "Alice updated the billing email to bob@example.com," you have logged a personal email address in your activity table. Ask whether the new value needs to appear at all. Often "Alice updated the billing email" is sufficient. Log that a change happened, not what it changed to, unless you have a documented business reason for the latter.

Pseudonymize actor identifiers. Store a stable pseudonymous ID in the event record rather than the raw user ID. Keep the mapping table in a separate service with tighter access controls. If you later need to delete a user, you update the mapping rather than scrubbing hundreds of event rows.

Set retention limits and enforce them. Most SaaS teams agree on a retention policy but do not implement the deletion job. A cron that runs weekly and hard-deletes or pseudonymizes events older than your policy (commonly 12-24 months for the user-facing feed, longer for the audit trail under specific legal basis) is not glamorous work but it is what keeps you compliant.

Document your legal basis. For enterprise customers, you will be asked what personal data your application stores and why. If your activity feed logs actor names, timestamps, and IP addresses, those are three separate data categories that each need a documented basis. Legitimate interest covers most audit-trail use cases; just document it in your privacy policy.

Redact sensitive fields. Payment details, passwords, security tokens, and health-related data should never appear in event payloads - not even in the internal audit log. Build a field-level redaction list into your event serializer and apply it before anything hits the database.

Schema Design

A minimal event schema that covers most B2B SaaS use cases:

event_id        uuid, primary key
tenant_id       uuid, indexed
actor_id        uuid (pseudonymous)
actor_type      enum: user | system | api_key
resource_type   varchar: project | contact | invoice | ...
resource_id     uuid
action          varchar: created | updated | deleted | invited | ...
metadata        jsonb (non-PII context: field names changed, counts, etc.)
ip_address      inet (hashed or omitted per policy)
occurred_at     timestamptz, indexed
feed_visible    boolean (false for internal-only events)
aggregation_key varchar (null for standalone events)

The aggregation_key column is worth calling out. When you detect that an event belongs to an aggregation group (same actor, same resource, same time window), set a shared key. Your feed query then collapses all rows sharing a key into one entry, picking the latest timestamp and merging metadata. This avoids a separate aggregation pass.

For the feed projection, a materialized view or a separate feed_items table built by a background worker gives you sub-millisecond read latency. Index on (tenant_id, occurred_at DESC) and paginate with cursor-based pagination (keyset pagination) rather than OFFSET - activity feeds can run to millions of rows per tenant.

Delivering the Feed to the Frontend

A few implementation details that often get overlooked:

Incremental loading over websockets or SSE is almost always worth it for real-time feeds. Polling every 10 seconds is simpler but produces visible lag and unnecessary load. Server-Sent Events are easier to operate than websockets for unidirectional streams.

Cache aggressively at the tenant level. Activity feeds are read far more than they are written. A Redis sorted set keyed by tenant, with score as timestamp, lets you serve the last N events in O(log N) without hitting the database on every request.

Separate read and write paths. The event write path needs to be low-latency and highly available - a failed event write during a user action feels like a bug. The feed read path can tolerate eventual consistency. Decouple them with an async worker rather than writing to both stores in the same transaction.

Connecting the Feed to Your Custom Software

Activity feeds are infrastructure. They show up in almost every custom software development project past a certain complexity threshold, and they tend to be underspecified in the initial brief because they look simple from the outside.

The teams that end up with the most maintainable implementations are those that treat the audit log and the feed as first-class domain concerns from day one - not something bolted on after the core features ship. Once you have a hundred thousand rows of inconsistently structured events, retrofitting aggregation and privacy controls is painful.

If your application is in early growth and you are hitting the point where the activity feed is becoming a maintenance burden - or where compliance questions about data retention are arriving from enterprise prospects - that is a good moment to step back and redesign the data layer. It is considerably cheaper than doing it after a data protection audit.

A Note on Third-Party Solutions

Libraries like logidze (PostgreSQL-level change tracking) and services like Inngest or Temporal handle parts of this problem. They are worth evaluating, especially if your team is small. The tradeoffs: third-party change-capture tools give you completeness at the cost of control over the schema; workflow engines solve the durability problem but add operational overhead.

For most B2B SaaS applications at the 10-100k user scale, a well-designed in-house event table with a background aggregation worker is the right call. It keeps the data model transparent, makes compliance easier to reason about, and avoids coupling your audit trail to a vendor's retention policy.

Summary

A good B2B SaaS activity feed rests on four decisions: a clear event taxonomy (user-initiated vs. system vs. state-change), an aggregation strategy applied before storage, a hard separation between the audit trail and the user-facing feed, and a privacy model that minimizes stored personal data from the start. Get those right and the implementation is straightforward. Skip them and you will be retrofitting all four under pressure later.

If you are designing or rearchitecting an activity feed as part of a larger web application development effort and want a second opinion on the data model or privacy approach, get in touch at hello@wolf-tech.io or visit wolf-tech.io.


FAQ

How long should I retain activity feed data? For the user-facing feed, 12 months is a common default with enterprise customers sometimes requiring 24 months. For the internal audit trail, your legal basis will determine the limit - often 3-7 years for financial events under local regulations. Whatever you decide, automate the deletion or pseudonymization job and test it.

Should I log the old and new values of changed fields? Only if you have a documented reason. Logging change diffs is useful for undo functionality or compliance in regulated industries. It also significantly increases the personal data you store. If you do log diffs, apply field-level redaction for anything sensitive and store diffs in a separate table with a stricter retention policy than the main event log.

Can I use an existing library instead of building from scratch? Yes, especially for the audit trail layer. PostgreSQL-level solutions like logidze or temporal_tables handle change capture automatically. For the user-facing feed, you will almost certainly need custom aggregation logic, but you can build it on top of whatever the library captures.