---
title: "Feeds and timelines"
description: "Follows, blocks and mutes, home timelines that fan out on write and on read, and trending per region, with a feed that has an end."
section: "interfaces"
status: "Building for 0.4 · ships as a experimental API"
spec: "feeds"
canonical: "https://mainsdk.dev/docs/interfaces/feeds"
updated: "2026-10-28"
---

# Feeds and timelines

> Follows, blocks and mutes, home timelines that fan out on write and on read, and trending per region, with a feed that has an end.

Status: Building for 0.4 · ships as a experimental API · Canonical: https://mainsdk.dev/docs/interfaces/feeds · Updated: 2026-10-28

`feeds` gives your app a follow graph and a home timeline that stays fast at any follower count, plus trending counts per region. The feed is chronological by default and has an end.

People follow, block and mute each other, post and read, whether an author has ten followers or ten million. Reach for it for a social app, a community or an activity stream.

The feed is chronological by default. You opt in to ranking, and every ranked entry carries a reason and a way back to chronological. The feed has an end: no endless scroll, and no new items inserted while a person reads.

## At a glance

- Status: Building for 0.4 · ships as a experimental API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/feeds.md
- Category: Data

Calls:

- `feeds.follow(follower, target)`
- `feeds.unfollow(follower, target)`
- `feeds.followers(user)`
- `feeds.following(user)`
- `feeds.counts(user)`
- `feeds.requests(user)`
- `feeds.accept(user, from)`
- `feeds.decline(user, from)`
- `feeds.block(user, target)`
- `feeds.unblock(user, target)`
- `feeds.mute(user, target, { until? })`
- `feeds.unmute(user, target)`
- `feeds.relation(a, b)`
- `feeds.publish(author, { id, visibility })`
- `feeds.edit(author, id)`
- `feeds.remove(by, id)`
- `feeds.timeline(user, { order?, cursor?, continue? })`
- `feeds.profile(viewer, author)`
- `feeds.ranker(fn)`
- `feeds.count(key, { by })`
- `feeds.trending({ window?, k? })`

In main.yaml:

```yaml
feeds:
  driver: db
  items: posts
  fanout: { threshold: 10000, batch: 500 }
  timeline: { order: chronological }
  page: { end: true, pause_after: 200 }
  trending: { window: 1h, k: 20, min-count: 25 }
```

Guarantees, whatever the driver:

- New items never written to timelines in the request; authors see their own items at once
- Edits, deletes and moderation apply on the next read of every timeline
- Chronological by default; a ranked timeline explains each item and can always be switched off
- Every timeline has an end and a pause, and new items arrive as a count, never pushed into the list
- Trending stores counts only, never a person


## Drivers

Pick one in main.yaml with `feeds.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in on db | `db` | yes |  |
| Local (in process | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-FEED-0010` | check | A feed with no end: `page.end: false`, `page.pause_after` off or above 500, or a client that requests the next page with no user action |
| `MAIN-FEED-0011` | check | `timeline.order: ranked`, or a `feeds.ranker`, in an app with a children's audience and no chronological-only rule for it |
| `MAIN-FEED-0012` | check | `trending.min-count` below 10, or a trending key built from a labelled field |
| `MAIN-FEED-0013` | check | `fanout.threshold` above 100,000, `timeline.max` above 5,000 or `max-following` above 50,000 |
| `MAIN-FEED-0001` | runtime | Follow, request or profile read refused: one side blocks the other (403) |
| `MAIN-FEED-0002` | runtime | A follow or item across tenants (403) |
| `MAIN-FEED-0003` | runtime | The item doesn't exist, is removed, or isn't visible to the viewer (404) |
| `MAIN-FEED-0004` | runtime | Edit or remove by someone who isn't the author and whom `access` doesn't allow `feeds.remove` (403) |
| `MAIN-FEED-0005` | runtime | Following more than `max-following` (422) |
| `MAIN-FEED-0006` | runtime | A fan-out batch failed after its last retry and is in the dead-letter queue; affected timelines are served by merging at read time (logged) |
| `MAIN-FEED-0007` | runtime | The ranker threw, returned an entry without a reason, or passed `ranked.budget`; the chronological timeline was served (logged) |
| `MAIN-FEED-0008` | runtime | A pushed value loosens `page.end`, `page.pause_after`, `trending.min-count` or `max-following`; refused, last good kept |
| `MAIN-FEED-0009` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| feeds@1 contract | 1 | 0.4 | `feeds#contract` |
| Follow graph with requests and counts | 1 | 0.4 | `feeds#graph` |
| Block and mute | 1 | 0.4 | `feeds#block-mute` |
| Fan-out on write | 1 | 0.4 | `feeds#fanout-write` |
| Fan-out on read above a threshold and read-your-writes | 1 | 0.4 | `feeds#fanout-read` |
| Edits and deletes shown at once | 1 | 0.4 | `feeds#edits-deletes` |
| Chronological and ranked timelines | 1 | 0.4 | `feeds#order` |
| Caught-up end, pause and children's rules | 1 | 0.4 | `feeds#ethics` |
| Trending per region | 1 | 0.4 | `feeds#trending` |
| Tenants and regions | 1 | 0.4 | `feeds#tenancy-regions` |
| Erasure | 1 | 0.4 | `feeds#erase` |
| Feed checks | 1 | 0.4 | `feeds#checks` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
feeds:
  driver: db
  items: posts
```

```ts title="app/post.ts"
import { feeds } from "mainsdk";

declare const user: { id: string };
declare const post: { id: string };

await feeds.publish(user, { id: post.id, visibility: "public" });
const home = await feeds.timeline(user, { limit: 20 });   // ends with a caught-up or pause entry
```

`feeds` stores references to your records, not copies. `items` names the `db` model whose records are the feed items.

## Configuration

```yaml title="main.yaml"
feeds:
  driver: db                       # db (default) | local for tests
  items: posts                     # the db model whose records are feed items
  fanout:
    threshold: 10000               # authors with more followers are merged at read time
    batch: 500                     # timeline writes per job
  max-following: 5000
  timeline:
    order: chronological           # chronological (default) | ranked
    max: 800                       # entries kept per timeline
    keep: 30d
  page:
    limit: 20                      # default page size; at most 100
    end: true                      # a caught-up marker where the last session ended
    pause_after: 200               # items per session before a pause marker; 50 for children
  ranked:
    candidates: 500                # entries ranked per request
    budget: 50ms                   # ranker time per page; over it, chronological is served
  trending:
    window: 1h
    k: 20
    min-count: 25                  # keys counted for fewer people are hidden; may only be raised
  realtime: true                   # tell viewers how many new items wait
```

## What you get

A follow graph with blocks and mutes, home timelines that fan out on write (merged at read time for large authors), profiles and trending keys. Every call below takes the acting user as its first argument.

### Calls

### Follow graph

```text
feeds.follow(follower, target)                  // { state: "following" | "requested" }; private accounts get a request
feeds.unfollow(follower, target)
feeds.followers(user, o?)                       // a page of accounts, newest first
feeds.following(user, o?)
feeds.counts(user)                              // { followers, following }
feeds.requests(user, o?)                        // pending follow requests
feeds.accept(user, from)
feeds.decline(user, from)
feeds.relation(a, b)                            // { following, followedBy, requested, blocking, blockedBy, muting }
```

Following yourself, or following twice, returns the current state and changes nothing. Following more than `max-following` fails with `MAIN-FEED-0005`.

### Block and mute

```text
feeds.block(user, target)
feeds.unblock(user, target)
feeds.mute(user, target, { until? })            // hides the target from the muter's timeline only
feeds.unmute(user, target)
```

A block deletes follows and requests both ways, refuses new ones with `MAIN-FEED-0001`, and hides each side's items from the other. A mute leaves follows alone. Neither tells the other person.

### Items and timelines

```text
feeds.publish(author, { id, visibility })       // visibility: "public" | "followers"
feeds.edit(author, id)                          // bumps the version after the item record changed
feeds.remove(by, id, { reason? })               // the author, or a subject that access allows
feeds.timeline(user, { order?, limit?, cursor?, continue? })
feeds.profile(viewer, author, o?)               // one author's items, as the viewer may see them
feeds.ranker(fn)                                // register the ranking function
```

`feeds.publish` resolves once the record reference and the fan-out job are accepted, and writes no follower's timeline in the request. An author sees their own new item at the top of their chronological timeline on the next read.

A timeline entry is one of three kinds:

| Kind | Meaning |
| --- | --- |
| `item` | A feed item. A ranked entry also carries a `reason`. |
| `caught-up` | The person has seen everything after this point. |
| `pause` | `pause_after` was reached. There is no `next` until a request with `continue: true`. |

`order: "chronological"` is always accepted, so a person can leave ranking at any time. For a children's audience the timeline is always chronological and pauses after at most 50 items.

### Trending

```text
feeds.count(key, { by })                        // one occurrence of a key such as "tag:launch"
feeds.trending({ window?, k? })                 // { region, window, keys: [{ key, count }] }
```

`feeds.count` is synchronous and in memory, and a job flushes it to `db` each minute. Each person counts once per key per window, using a salted hash that rotates every window. No person id is stored. Keys counted for fewer than `min-count` people are hidden, and regions are never merged.

## Examples

### Rank with a reason

```ts title="app/ranker.ts"
import { feeds } from "mainsdk";

declare function affinity(user: { id: string }, item: { id: string }): number;

feeds.ranker((candidates, { user }) =>
  candidates.map((c) => ({ id: c.id, score: affinity(user, c), reason: "Because you follow this author" })),
);
```

A ranker that throws, omits a reason or passes `ranked.budget` makes `feeds.timeline` serve the chronological timeline and log `MAIN-FEED-0007`.

### Show new-item counts

With `realtime: true`, each fan-out batch publishes the event `feeds.new` with `{ count }` on `user:<id>`. It never carries an item. Show "3 new posts" and let the person ask for them.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-FEED-0001` | One side blocks the other (403) | Nothing; the block is the point |
| `MAIN-FEED-0002` | A follow or item across tenants (403) | Keep both in one tenant |
| `MAIN-FEED-0003` | The item is missing, removed or not visible to the viewer (404) | Check the id and visibility |
| `MAIN-FEED-0004` | Edit or remove by someone who is not the author and not allowed by `access` (403) | Allow `feeds.remove` for moderators |
| `MAIN-FEED-0005` | Following more than `max-following` (422) | Raise the limit, within the check ceiling |
| `MAIN-FEED-0010` | A feed with no end, or a client that requests the next page with no user action | Keep `page.end` on and wait for a person |
| `MAIN-FEED-0011` | Ranking in an app with a children's audience | Serve chronological only |
| `MAIN-FEED-0012` | `trending.min-count` below 10, or a key built from a labelled field | Raise it; count tags, not people |
| `MAIN-FEED-0013` | `fanout.threshold`, `timeline.max` or `max-following` above its ceiling | Lower it |

Two codes are runtime only: `MAIN-FEED-0006` (a failed fan-out batch, served by merging at read time) and `MAIN-FEED-0008` (a pushed value that loosens a limit is refused). See [check codes](https://mainsdk.dev/docs/reference/check-codes).

`mainsdk check` has a `feeds` gate for `MAIN-FEED-0010` to `0013`, each reported with its file and line.

## Swapping drivers

`feeds.driver` is `db` (the default, on your app's `db` stores) or `local`, an in-process SQLite store for tests. Your calls do not change when you change it.

## Testing

`driver: local` keeps the graph, items and timelines in an in-process SQLite store, so tests run offline on the fake clock and the local job queue. See [Test your app](https://mainsdk.dev/docs/guides/testing).

## Pushed config

A push may only tighten (ADR-0017): `feeds.page.end` is never turned off, `feeds.page.pause_after` and `feeds.max-following` only go lower, `feeds.trending.min-count` only goes higher. A looser value is refused with `MAIN-FEED-0008`, the last good value stays, and the refusal is written to the audit log.

## What is connected, and what is not

`app.feeds` (and `import { feeds } from "mainsdk"`) is built on first use from the `feeds` section over the app's `db` SQL stores, tenancy, `jobs`, `access` and the log. These modules are not built yet, so set `app.socialPorts.feeds = { ... }` before the first use if you have your own:

| Port | What it does | Without it |
| --- | --- | --- |
| `realtime` | Publishes `feeds.new` with the pending count | The count is read with the timeline |
| `items` | Reads the item record in the author's region to check its owner | Ownership is not checked against the `feeds.items` model |
| `accounts` | Says whether `users.private` is set | Every account is public |
| `audience` | Analytics' children rule | A child is known by the subject's own fields |
| `moderation` | Says whether `moderate` removed an item | Nothing is removed by moderation |
| `erase` | Registers `feeds.erase` with `privacy.erase` | Call the erase hook yourself |

## Security and privacy

- A timeline entry holds only the item id, author id and time, and lives in the follower's region. Item records stay in their author's region.
- Follow, block and mute changes are audited without content and visible only to the person who made them.
- `privacy.erase` removes a person's follows, blocks, mutes, item references, timeline entries and counts.
- A link to an item goes through [`links`](https://mainsdk.dev/docs/interfaces/links), not through `feeds`.

## FAQ

**Are likes, replies and reposts built in?** No. Model them as records and count them for your ranker.

**Can I push people more items while they read?** No. A timeline never inserts items while a person reads; use the `feeds.new` count.

## Next

- [Build a feed with an end](https://mainsdk.dev/docs/guides/build-a-feed-with-an-end): the steps, end to end.
- [Realtime updates](https://mainsdk.dev/docs/interfaces/realtime)
- [Jobs and queues](https://mainsdk.dev/docs/interfaces/jobs)
- [Moderation](https://mainsdk.dev/docs/interfaces/moderate)
