skip to content

How do you build a live top-10 leaderboard in RethinkDB with a single changefeed?

level: middleimportance: should knowfreq 40%

answer

  1. Order the query, then limit it
  2. Sorting must be index-backed
  3. Feed watches the result set, not the table
  4. One change carries entering and departing rows
  5. An option primes you with current members

basics

~10 s

Attach a changefeed to an ordered, limited query: orderBy on an index, limit(10), then .changes({includeInitial: true}). The feed sends the current top ten first, then one change each time the window's membership shifts.

solid answer

~50 s

Use an order-by-limit changefeed. Create an index on the score, then run `r.table('scores').orderBy({index: r.desc('score')}).limit(10).changes({includeInitial: true})`. Two details make this work. The `orderBy` **must** use an index for a feed to be allowed — an in-memory sort cannot be maintained incrementally. And the feed reports changes to the *result window*, not to the table: when a document climbs into the top ten and displaces another, you receive one change whose `new_val` is the entering document and whose `old_val` is the one that fell out. `includeInitial: true` primes the client by emitting the current ten members first as changes with `old_val` null, so you never have to run a separate query and reconcile it with the feed. Adding `includeOffsets: true` also tells you which position changed, so the client can splice its local array instead of re-sorting.

code

javascript · 7 lines
javascript
await r.table('scores').indexCreate('score').run(conn);

const feed = await r.table('scores')
  .orderBy({ index: r.desc('score') })
  .limit(10)
  .changes({ includeInitial: true, includeOffsets: true })
  .run(conn);

go deeper

for a junior

Recall the shape of the query: orderBy on an index, limit, then .changes(). Knowing that RethinkDB can push top-N updates at all is the level-appropriate answer.

for a middle

Explain why the ordering must be index-backed and why a change on this feed describes the window's membership — one document entering, another leaving — rather than a single row's history.

for a senior

Show the operational details: priming with includeInitial to close the read-then-subscribe race, using offsets to splice the client array, and adding a tiebreaker when the boundary slot churns.

for a principal

Weigh whether the leaderboard belongs in the database's push layer at all: feed count scales with connected clients, and a materialized top-N document or an edge cache may serve a large audience more cheaply.

## The problem "Show the current top N, and keep it current" is the canonical real-time UI. Done naively it becomes: run the query, poll every second, re-render. RethinkDB's order-by-limit changefeed collapses that into one subscription. ```javascript r.table('scores') .orderBy({ index: r.desc('score') }) .limit(10) .changes({ includeInitial: true, includeOffsets: true }) .run(conn); ``` ## Why the index is mandatory A changefeed has to decide, for each incoming write, whether the result set changed. For an ordered window that means knowing where the written document falls in the order without re-scanning the table. An index gives the server exactly that. Consequently `orderBy` in a feed must be index-backed — `r.desc('score')` here refers to a secondary index created with `indexCreate('score')`, not to a client-side sort. Sorting an arbitrary in-memory stream and then subscribing to it is not supported, and the query will be rejected rather than silently degrading. ## Changes are changes to the window This is the part candidates get wrong. On a plain table feed, `old_val`/`new_val` describe one document before and after. On an order-by-limit feed they describe the **membership of the window**: - A new document scores high enough to enter the top ten and pushes the tenth-place document out: one change arrives with `new_val` = the entering document and `old_val` = the departing one. - A document already inside the window has its score edited: `old_val` and `new_val` are the two versions of that same document, and its position may move. - A write that lands entirely outside the window — an eleventh-place score improving but still eleventh — produces nothing, because the answer to the query did not change. That last property is the efficiency win: the server filters for you, and a hot table with a stable leaderboard produces almost no traffic. ## Priming the client Without help, a client that subscribes has an empty list and only learns about entries that subsequently change. `includeInitial: true` fixes this: the feed first emits the current members as changes with `old_val` null, then continues into live updates. Because both arrive on one cursor, there is no window between "I read the table" and "I started listening" in which an update can be lost — the classic race in hand-rolled read-then-subscribe code. With `includeStates: true`, the feed also interleaves marker documents carrying a `state` field so the client can tell when the initial batch is finished and the feed has gone live — useful for showing a spinner until the first render is complete. ## Splicing efficiently `includeOffsets: true` is specific to order-by-limit feeds. Each change then carries the old and new positions of the affected document within the window, so a client holding an array of ten items can remove at one index and insert at another instead of rebuilding and re-sorting. For a ten-row leaderboard the saving is trivial; for a large scrolling window rendered in a UI framework that diffs on identity, it is the difference between a smooth list and a full re-render. ## Practical cautions The window is defined by the query, so changing N means opening a new feed. Ties in the ordering key can make membership at the boundary churn — add a deterministic tiebreaker such as a compound index on score plus the primary key if you see the last slot flapping. And the feed inherits every general changefeed limitation: it lives only while the cursor is open, it is served by the shard primary, and on a disconnect the client must resubscribe — with `includeInitial` doing double duty as the resync mechanism.

  • Why does includeInitial remove a race that read-then-subscribe has?
    Doing a query and then opening a feed leaves a gap in which writes land after the read but before the subscription, and are never seen. `includeInitial: true` makes the server deliver the current members and the subsequent changes on one ordered cursor, so no update can slip between the two phases.
  • What does includeStates add on top of includeInitial?
    It interleaves marker documents carrying a `state` field, so the client can distinguish "still sending the initial result set" from "now live". UIs use it to hold a loading indicator until the first complete render is available instead of painting a partially filled list.
  • The last slot of your leaderboard flaps constantly between two documents. What is the likely cause?
    Ties on the ordering key. When two documents share a score, their relative order at the window boundary is not pinned, so ordinary writes can shuffle membership. Ordering on a compound index that appends a unique tiebreaker such as the primary key makes the boundary deterministic and the churn stops.

saying these in an interview costs you the question

  • Sorting client-side and then attaching a changefeed
  • Expecting a change for writes outside the top N
  • Reading the table first, then subscribing separately
  • Thinking old_val is always the same document as new_val
  • Believing limit can be changed on an open feed

context