Skip to content

Ranking

Stream allows you to configure your own ranking method to determine the order of activities inside a feed.

Info:

Ranked feeds are not available for free plans. Contact support after upgrading your account to enable ranked feeds for your organization.

An example ranking config is shown below:

const response = await serverClient.feeds.createFeedGroup({
  id: "mytimeline",
  ranking: { type: "expression", score: "decay_linear(time) * popularity" },
  activity_selectors: [
    {
      type: "following",
    },
  ],
});

You can also update built-in feed groups with your own ranking config:

await client.feeds.updateFeedGroup({
  id: "<id of feed group to update>",
  // Fields to update
});

Supported types for configuration:

name description
recency Ranks activities based on their timestamp, with newer activities appearing first
expression Uses a custom mathematical expression to calculate scores for ranking activities
interest expression ranking extended with interest weights

Read on to learn the syntax for expression and interest based ranking.

For high-traffic feeds where every reader sees the same order, hot feed cache can serve a shared cached result. It supports recency and expression ranking (including external ranking) but not interest ranking or expressions that use per-user state such as is_read and is_seen.

The result of the ranking expression should be a number (called score). Activities in the feed will be ordered by score (highest score first).

Example of a simple ranking expression, order activities by popularity:

await serverClient.feeds.createFeedGroup({
  id: "myid",
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "expression",
    score: "popularity",
  },
});
Info:

Custom ranking is applied after activity selectors filtered activities. Each activity selector selects a maximum of 1000 activities. If you combine selectors, 1000 is multiplied by the number of selectors you have. You can use a maximum of 3 selectors in a group/view.

Ranking aggregated feed groups

If your feed also uses aggregation, you can rank aggregated groups by score instead of the default updated_at ordering.

Set aggregation.score_strategy together with ranking:

{
  "aggregation": {
    "format": "{{ type }}_{{ time.strftime('%Y-%m-%d') }}",
    "score_strategy": "max"
  },
  "ranking": {
    "type": "expression",
    "score": "popularity * 2 + comment_count"
  }
}

Supported score_strategy values:

value behavior
sum Sum of all member activity scores in the group
max Highest member activity score in the group
avg Average of member activity scores in the group
Info:

score_strategy requires aggregation.format. If ranking or aggregation is not configured, groups fall back to the default aggregated ordering (updated_at descending for non-story feeds, created_at descending for stories).

Aggregated score sorting and pagination

When score_strategy is configured, aggregated groups are sorted by:

  1. score descending
  2. updated_at descending (tiebreaker, most recently active group first)

Pagination for scored aggregated groups uses ranked-feed style offset pagination (cursor includes offset, limit, and ranking version). If ranking config changes between page requests, the cursor expires and you should restart pagination from page 1.

Aggregated score in responses

When score-based aggregation ranking is enabled, aggregated_activities[].score contains the computed group score:

{
  "aggregated_activities": [
    {
      "group": "reaction_2026-03-30",
      "score": 42.5
    }
  ]
}

Ranking expression syntax

Activity data

It's not possible to access all fields of an ActivityResponse inside the ranking context. Below you'll find the list of supported fields:

name description
type The type of the activity (e.g. the content type string set when creating the activity)
time UNIX timestamp of when the activity was created
popularity The popularity score of an activity (formula: activity.popularity = reactions + comments * 2 + bookmarks * 3 + shares * 3)
reaction_count The sum of all reactions
reaction_counts Reaction counts by type, you can access specific types with dot notation (for example, reaction_counts.like)
comment_count How many comments the activity has
bookmark_count How many bookmarks the activity has
share_count How many shares the activity has (Increased when an activity is set as the parent_id of another activity)
custom The custom field of the activity, nested fields can be accessed with . notation, for example: custom.topic
selector_source Which activity selector provided this activity (e.g., "following", "popular", "interest"). Only set when using multiple activity selectors. See Ranking by Selector Source for usage examples.
interest_score Only available when using interest type ranking. A number between 0 and 1 reflecting how much a given activity matches the user's interests. See Interest weights to see how a user's interests are computed/configured.
preference_score Available when using expression or interest type ranking. A number reflecting how much a given activity matches the user's preferences based on activity feedback (show more/less). Activities with interest_tags that have strong dislike are filtered out. See Activity feedback for more information.
is_read Boolean. Available when track_read is enabled on a custom feed group or the built-in notification group (including flat). true if the activity (or its aggregated group) has been marked as read. Uses time-based invalidation. Enterprise plan only. See Ranking by Read/Seen Status.
is_seen Boolean. Available when track_seen is enabled on a custom feed group or the built-in notification group (including flat). Same time-based invalidation as is_read. Enterprise plan only. See Ranking by Read/Seen Status.

It's possible to set default values if you expect that some data might be undefined for some activities. However it's not possible to set default values to built-in fields like popularity or share_count.

await serverClient.feeds.createFeedGroup({
  id: "myid",
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "expression",
    score: "popularity * (custom.isBoosted ? 1.5 : 1)",
    defaults: {
      custom: {
        isBoosted: false,
      },
    },
  },
});

Data types

The following data types are supported in the ranking expression context:

name example
Boolean true, false
Integer 36, 0b101010
Float 0.2
String "foo"
Array ["a", "b", "c"]
Map {foo: "bar"}
Nil nil

Functions

The following functions can be used in ranking expressions:

name description
ln(x) Natural logarithm function
log10(x) Logarithm function base 10
sin(x) Trigonometric sine function
cos(x) Trigonometric cosine function
tan(x) Trigonometric tangent function
asin(x) Arcsine function
acos(x) Arccosine function
atan(x) Arctangent function
abs(x) Absolute value
min(a,b) Minimum value
max(a,b) Maximum value
trunc(x) Truncates to the nearest integer value
round(x) Rounds to the nearest integer value
decay_linear(t) Linear decay, see Decay & Ranking for more information
decay_exp(t) Exponential decay, see Decay & Ranking for more information
decay_gauss(t) Gaussian decay, see Decay & Ranking for more information
rand_normal() Returns a normally distributed number in the range [-inf, +inf] with standard normal distribution (stddev = 1, mean = 0)
rand_normal(a,b,σ,µ) Returns a normally distributed number in the range [a, b] with specific normal distribution (stddev = σ, mean = µ)
rand() Returns a random number in the range [0, 1.0)
to_unix_timestamp(t) Converts a time value to a UNIX timestamp
dist(lat1,lng1,lat2,lng2,unit) Returns the distance between the two points given by (lat1,lng1) and (lat2,lng2). By default the unit is in kilometers, but the unit can also be M for miles or N for nautical miles. This function can be combined with the external ranking parameters to rank activities based on a user's distance to them.
map_lookup(key, map) Look up value from a map by key, see Arrays and Maps for more information
sum_map_lookup(keys, map) Sum values from a map for multiple keys, see Arrays and Maps for more information
in_array(needle, haystack) Check if a value exists in an array, see Arrays and Maps for more information

Operators

The following operators can be used in ranking expressions:

  • Arithmetic operators: +, -, *, /, % (modulus), ^, ** (exponent)

  • Logical operators: not/!, &&/ and, ||/or

  • Conditional: x ? y : z, ??, if/else

  • Comparison: ==, !=, <, >, <=, >=

  • Control order of evaluation with parentheses ()

  • Array/map operators: [] (index), ., ?., in (checks if an array/map has an item/key)

  • String operators: + (concatenation), contains, startsWith, endsWith

This lets you construct a scoring algorithm like this:

"score":"(a > 2 || (b > 4 && c > 3)) ? 1 : -1"

Constants

The following constants and helpers are also available to use in ranking expressions:

name description
e / E Euler's number
pi / PI π
current_time UNIX timestamp of the current time

Decay & Ranking

Stream supports linear, exponential, and Gaussian decay. For each decay function, we support 4 arguments: Origin, Scale, Offset, and Decay. You can pass either a timedelta string (such as 3d, 4w), or a numeric value.

Parameters

name description default
origin The best possible value. If the value is equal to the origin, the decay function will return 1. now
scale Determines how quickly the score drops from 1.0 to the decay value. If the scale is set to "3d", the score will be equal to the decay value after 3 days. 5d
offset Values below the offset will start to receive a lower score. 0
decay The score that a value at scale distance from the origin should receive. 0.5
direction left, right or both. If right is specified, only apply the decay for the right part of the graph. both
Info:

You can use s, m, h, d and w to specify seconds, minutes, hours, days and weeks respectively.

The example below defines a simple_gauss with the following parameters:

  • scale: 5 days

  • offset: 1 day

  • decay: 0.3

This means that an activity younger than 1 day (the offset) will return a score of 1. An activity that is exactly 6 days old (offset + scale) will get a score of 0.3 (the decay factor). The full example:

await serverClient.feeds.createFeedGroup({
  id: "myid",
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "expression",
    functions: {
      simple_gauss: {
        base: "decay_gauss",
        scale: "5d",
        offset: "1d",
        decay: "0.3",
      },
      popularity_gauss: {
        base: "decay_gauss",
        scale: "100",
        offset: "5",
        decay: "0.5",
      },
    },
    score: "simple_gauss(time)*popularity_gauss(popularity)",
  },
});

Interest weights

Interest weights help customize the ranking to users' interests. If you want to create a ranking expression that relies on interest weights, use interest type ranking.

serverClient.feeds.createFeedGroup({
  id: "myid",
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "interest",
    score: "interest_score * popularity",
  },
});

interest_score is computed by combining activity topics and the specific user's interests. The result is a number between 0-1.

Activity topics

The topic of an activity can be computed automatically using activity processors or by setting the interest_tags field via the Stream API.

User interests

A user's interests are computed automatically by the Stream API from the interest_tags on activities the user has reacted to. When ranking with interest type, the top five computed tags are used, each with a weight of 1.0.

You can also pass interest_weights when reading the feed. These weights are merged with the computed interests rather than replacing them:

  • Tags that appear only in the computed profile or only in interest_weights both contribute to interest_score.
  • If the same tag appears in both, the value from interest_weights is used (an explicit override for that tag).
  • If you omit interest_weights or pass an empty map, ranking uses only the computed interests.
await timeline.getOrCreate({
  interest_weights: {
    travel: 1,
    food: 0.5,
    exercise: -1,
  },
  user_id: "<user id>",
});

The range for interest weights is between -1 and 1. 1 means a user is very interested in a topic.

Preference score

preference_score reflects how much an activity matches the user's preferences based on activity feedback (show more/less). Activities with interest_tags that have strong dislike (based on user feedback) are filtered out from the feed. Other activities are ranked using preference_score which incorporates the user's activity feedback.

You can use preference_score in your ranking expressions with both expression and interest type ranking to boost activities that match user preferences:

// Using preference_score with expression type
serverClient.feeds.createFeedGroup({
  id: "myid",
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "expression",
    score: "preference_score * popularity",
  },
});

// Using preference_score with interest type
serverClient.feeds.createFeedGroup({
  id: "myid",
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "interest",
    score: "preference_score * interest_score * popularity",
  },
});

See Activity feedback for more information on how users can provide feedback that affects preference_score.

Ranking by Selector Source

When using multiple activity selectors, each activity is tagged with a selector_source field indicating which selector provided it. This enables you to prioritize activities from specific sources in your ranking expressions.

// Example 1: Simple boost - following activities get 2x multiplier
await serverClient.feeds.createFeedGroup({
  id: "timeline",
  activity_selectors: [
    { type: "following" },
    { type: "popular", min_popularity: 0 },
  ],
  ranking: {
    type: "expression",
    score: 'selector_source == "following" ? popularity * 2 : popularity',
    defaults: {
      selector_source: "", // Required when using selector_source in expressions
    },
  },
});

// Example 2: Multiple selectors with different bonuses
await serverClient.feeds.createFeedGroup({
  id: "timeline",
  activity_selectors: [
    { type: "following" },
    { type: "interest" },
    { type: "popular", min_popularity: 0 },
  ],
  ranking: {
    type: "expression",
    score: `
      (selector_source == "following" ? 10 : 0) + 
      (selector_source == "interest" ? 5 : 0) + 
      popularity
    `,
    defaults: {
      selector_source: "",
    },
  },
});

Important notes:

  • Defaults required: You must provide selector_source in the defaults map (typically as an empty string "")
  • Duplicate activities: If an activity matches multiple selectors, it keeps the selector_source of the first selector in your configuration
  • Valid sources: following, popular, interest, query, proximity, current_feed, follow_suggestion

Ranking by Read/Seen Status

When track_read or track_seen is enabled on a feed group, you can use is_read and is_seen as variables in ranking expressions. On flat content feeds, enable them with activity_marks; notification feeds use their notification configuration. The ranking expression must only reference dimensions the group tracks.

Info:

Ranking by is_read / is_seen is only available on Enterprise plans. Contact support to enable this feature for your organization.

For example, a custom flat feed group that ranks unread activities higher, while still sorting by popularity:

await serverClient.feeds.createFeedGroup({
  id: "ranked_timeline",
  activity_marks: {
    track_seen: true,
    track_read: true,
  },
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "expression",
    score: "popularity - (is_read ? 100 : 0)",
  },
});

To rank unseen activities first instead (this does not include popularity):

await serverClient.feeds.createFeedGroup({
  id: "unseen_timeline",
  activity_marks: {
    track_seen: true,
    track_read: true,
  },
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "expression",
    score: "is_seen ? 0 : 100",
  },
});

The same expressions work on notification feeds, where marks may be resolved per activity or aggregated group.

Example expressions

Expression Effect
is_seen ? 0 : 100 Unseen activities ranked first
is_read ? 0 : 100 Unread activities ranked first
popularity - (is_read ? 10 : 0) Penalize read activities by 10 points
popularity - (is_read ? 10 : 0) - (is_seen ? 5 : 0) Penalize both read and seen activities
(is_seen ? 0 : 1000) + decay_linear(time, 48) Unseen activities first, with time decay

Aggregated feeds

For aggregated feeds, is_read and is_seen are resolved at the group level. All activities within an aggregated group share the same read/seen status. On flat feeds, they are resolved per activity.

Info:

Ranking by is_read / is_seen on content feeds requires matching activity_marks.track_read / activity_marks.track_seen configuration. activity_marks cannot be combined with notification, aggregation, or stories configuration.

External data

External data lets you add dynamic data to your ranking expressions. External data is provided when reading the feed, so you can provide user-specific data here.

Accessing external data in ranking expressions

name description
external The ranking configuration provided when reading the feed, nested fields can be accessed with . notation, for example: external.foo
serverClient.feeds.createFeedGroup({
  id: "myid",
  ranking: {
    type: "expression",
    score:
      "popularity * external.popularity_multiplier + share_count * external.share_multiplier",
    defaults: {
      external: {
        // Provide default values if external data isn't provided when reading the feed
        popularity_multiplier: 1,
        share_multiplier: 1,
      },
    },
  },
});

Providing external data when reading the feed

await timeline.getOrCreate({
  external_ranking: {
    popularity_multiplier: 1, // normal weight for popularity
    share_multiplier: 100, // very high boost for share_count
  },
  user_id: "<user id>",
});

Arrays and Maps

Stream provides three powerful functions for working with arrays and maps in ranking expressions. These functions are particularly useful for creating dynamic, personalized ranking algorithms that can respond to external data.

map_lookup(key, map)

Looks up a single value from a map by key. If the key doesn't exist, it returns 0.0.

await serverClient.feeds.createFeedGroup({
  id: "myid",
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "expression",
    score: "popularity + map_lookup(stock, external.stock_boosts)",
    defaults: {
      stock: "",
      externals: {
        stock_boosts: {},
      },
    },
  },
});

Providing external data when reading the feed

await timeline.getOrCreate({
  external_ranking: {
    stock_boosts: {
      apple: 0.9,
      tesla: 0.5,
      microsoft: 0.3,
    },
  },
  user_id: "<user id>",
});

sum_map_lookup(keys, map)

Sums values from a map for multiple keys. This is useful when an activity has multiple tags or categories that should all contribute to the score.

await serverClient.feeds.createFeedGroup({
  id: "myid",
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "expression",
    score: "popularity + sum_map_lookup(stocks, external.stock_boosts)",
    defaults: {
      stocks: [],
      external: {
        stock_boosts: [],
      },
    },
  },
});

Providing external data when reading the feed

await timeline.getOrCreate({
  external_ranking: {
    stock_boosts: {
      apple: 10.0,
      tesla: 5.0,
      microsoft: 2.0,
    },
  },
  user_id: "<user id>",
});

// score = popularity + 10.0 + 5.0 = popularity + 15.0 (for activity with stocks: ['apple', 'tesla'])

in_array(needle, haystack)

Checks if a value exists in an array. Returns 1.0 if found, 0.0 if not found.

await serverClient.feeds.createFeedGroup({
  id: "myid",
  activity_selectors: [{ type: "following" }],
  ranking: {
    type: "expression",
    score: "popularity + in_array(stock, external.watchlist) * 10",
    defaults: {
      stock: "",
      externals: {
        watchlist: [],
      },
    },
  },
});

Providing external data when reading the feed

await timeline.getOrCreate({
  external_ranking: {
    watchlist: ["apple", "tesla", "microsoft"],
  },
  user_id: "<user id>",
});

// activities with stocks in the watchlist get a +10 boost, others get no boost.

Practical Examples

Stock Trading Feed:

score: "popularity + sum_map_lookup(stocks, external.stock_boosts) + in_array(market_sector, external.favorite_sectors) * 5";

Content Categorization:

score: "popularity + in_array(category, external.preferred_categories) * 3 + map_lookup(category, external.category_weights)";

Multi-tag Content:

score: "popularity + sum_map_lookup(tags, external.tag_boosts) + in_array(priority, external.high_priority_tags) * 10";

Inspecting ranking variables

When reading the feed, you can access the:

  • score of each activity (this is the result of the ranking expression)
  • score of each aggregated group (aggregated_activities[].score) when using aggregation.score_strategy
  • reaction count of each activity (the sum of all reactions)
  • standard fields like popularity can also be observed when reading the feed

These variables can help you understand the result of the ranking expression.

const feed = client.feeds.feed("timeline", "sara");
const response = await feed.getOrCreate({ user_id: "sara" });

console.log(response.activities[0].score);
console.log(response.activities[0].reaction_count);
console.log(response.activities[0].popularity);

Configuring ranking can be complex. Feel free to reach out to support if you have questions!

Experimenting with ranking

Feed groups let you define what activities should be included in the feed and the ranking to sort these activities.

By default all feeds in the given group will have the same settings. However, you might want to experiment with different selectors and rankings. Feed views let you do that by overriding the group's default settings.

Info:

Note that any write operation to feed groups/views can take up to 30 seconds to propagate to all API nodes.