#I'm building an discover view with items from the database (recent posts)

55 messages · Page 1 of 1 (latest)

mint crag
#

The problem is that it will show most recent posts and is scrollable (paginated), but... anything a user will post something it will refresh for ALL users. That's a bit scary for data consumption.

fresh lavaBOT
#

Hi @mint crag. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:

#

You’re running into Convex’s reactivity: any query that “touches” the documents being written will be invalidated and re-run for all subscribed clients. That’s great for UX, but can be wasteful for a global “recent posts” feed that updates constantly. [Queries scale]

There are a few patterns in the docs/Stack posts that help you dial this back:


1. Use paginated queries for the feed

Make sure your discover view is using a paginated query (.paginate + usePaginatedQuery) instead of .collect() or .take() over a large set. This keeps each query’s work bounded and is the recommended pattern for large feeds. [DB pagination; Queries scale]

// convex/posts.ts
import { query } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";

export const listRecent = query({
  args: { paginationOpts: paginationOptsValidator },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("posts")
      .order("desc")
      .paginate(args.paginationOpts);
  },
});
// React
import { usePaginatedQuery } from "convex/react";
import { api } from "../convex/_generated/api";

export function Discover() {
  const { results, status, loadMore } = usePaginatedQuery(
    api.posts.listRecent,
    {},
    { initialNumItems: 20 },
  );

  // render results, loadMore, etc.
}

This doesn’t stop re-runs, but ensures each re-run is cheap and safe.


2. Avoid unnecessary invalidations (indexes vs filters)

#

If your query uses .filter on a large table, any change in that table can invalidate it, even if the new/changed document wouldn’t actually appear in the page. Using an index to narrow the range is much better for caching and reactivity. [Queries scale; Discord thread]

Bad (over‑reactive):

await ctx.db
  .query("posts")
  .filter(q => q.eq(q.field("archived"), undefined))
  .order("desc")
  .paginate(args.paginationOpts);

Better (index on ["archived", "_creationTime"]):

await ctx.db
  .query("posts")
  .withIndex("by_archived_creationTime", q =>
    q.eq("archived", undefined),
  )
  .order("desc")
  .paginate(args.paginationOpts);

Now only changes in the indexed range invalidate the query.


3. If you don’t want every new post to appear live

By default, paginated queries are fully reactive: pages can grow/shrink as items are inserted/removed. [DB pagination Reactivity; Fully reactive pagination]

If that’s too “live” for a discover view, you have two options:

a) Anchor the feed to a cutoff timestamp

You can pass a “max creation time” into the query so that new posts after that time don’t show up until you explicitly change the arg. This keeps the query reactive for edits/deletes, but not for new inserts beyond the cutoff. [Discord: keep page from growing]

Example:

#
// convex/posts.ts
export const listRecentBefore = query({
  args: {
    paginationOpts: paginationOptsValidator,
    maxCreatedAt: v.number(), // e.g. _creationTime of first loaded item
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("posts")
      .withIndex("by_creationTime", q =>
        q.lte("_creationTime", args.maxCreatedAt),
      )
      .order("desc")
      .paginate(args.paginationOpts);
  },
});

On the client you’d:

  1. Run a small query (or .first()) once to get the current “top” _creationTime.
  2. Use that as maxCreatedAt for your paginated discover query.
  3. Optionally run a separate query for “newer than maxCreatedAt” to show a “New posts” badge without growing the main list. [Discord: keep page from growing]

This pattern does require you to manage that anchor value, but it stops the discover feed from constantly expanding as new posts arrive.

b) Make the UI less “twitchy” while still reactive

If your main concern is UX churn (loading states, flashing) rather than bandwidth, you can keep full reactivity but smooth the UI with a wrapper like useStablePaginatedQuery, which holds onto the last non‑loading result while a new page is loading. [Overreacting pagination]

// hooks/useStablePaginatedQuery.ts
import { useRef } from "react";
import { usePaginatedQuery } from "convex/react";

export const useStablePaginatedQuery = ((name, ...args) => {
  const result = usePaginatedQuery(name as any, ...(args as any));
  const stored = useRef(result);

  if (result.status !== "LoadingMore") {
    stored.current = result;
  }

  return stored.current;
}) as typeof usePaginatedQuery;
#

Then use this hook instead of usePaginatedQuery in your discover view.


4. When to worry about “data consumption”

Convex will re-run subscribed queries whenever referenced documents change; that’s by design. The main levers you have to keep this efficient are:

If you share your current query definition, I can suggest a concrete rewrite that reduces unnecessary reruns.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@mint crag Kapa.ai is still learning and improving, please let me know how I did by reacting below.

mint crag
#

@fresh lava I like the idea. Right now I have this

const scores = await ctx.db.query('feedScores')
  .withIndex('by_score').order('desc')
  .filter(q => q.eq(q.field('archived'), false))
  .paginate(paginationOpts);

If I add creationTime in the index how to still order by score ?

#

@fresh lava up

fresh lavaBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

It sounds like you want to “bump” the previous topic, not ask something new.

For your discover/recent-posts view, the key tools Convex gives you to reduce unnecessary re-runs and data transfer are:

  1. Paginated queries instead of full-list queries

Use .paginate on the server and usePaginatedQuery on the client so each re-run only ever deals with a bounded page, not the whole feed. [DB pagination; Overreacting pagination]

// convex/posts.ts
import { query } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";

export const listRecent = query({
  args: { paginationOpts: paginationOptsValidator },
  handler: async (ctx, { paginationOpts }) => {
    return await ctx.db
      .query("posts")
      .order("desc")
      .paginate(paginationOpts);
  },
});
// React
import { usePaginatedQuery } from "convex/react";
import { api } from "../convex/_generated/api";

export function Discover() {
  const { results, status, loadMore } = usePaginatedQuery(
    api.posts.listRecent,
    {},
    { initialNumItems: 20 },
  );

  // render results and a "Load more" button / infinite scroll
}
  1. Avoid broad .filter that tracks the whole table

If you filter without an index, any change in the table can invalidate the query. Use withIndex so Convex only tracks the indexed range. [Discord: paginated rerunning]

Bad (over‑reactive):

ctx.db
  .query("posts")
  .filter(q => q.eq(q.field("archived"), undefined))
  .order("desc")
  .paginate(paginationOpts);

Better (with index, e.g. .index("by_archived_created", ["archived", "_creationTime"])):

#
ctx.db
  .query("posts")
  .withIndex("by_archived_created", q =>
    q.eq("archived", undefined),
  )
  .order("desc")
  .paginate(paginationOpts);
  1. If you don’t want every new post to appear live

By default, Convex’s pagination is fully reactive: pages grow/shrink as items are inserted/removed. [DB pagination reactivity; Fully reactive pagination]

If that’s too much for a global discover feed, you can:

  • Anchor the feed to a cutoff time so new posts after that time don’t show up until you change the arg. This pattern is suggested in a Discord thread: pass a timestamp into the query and exclude newer items. [Discord: keep page from growing]

    Example shape:

    export const listRecentBefore = query({
      args: {
        paginationOpts: paginationOptsValidator,
        maxCreatedAt: v.number(),
      },
      handler: async (ctx, { paginationOpts, maxCreatedAt }) => {
        return await ctx.db
          .query("posts")
          .withIndex("by_creationTime", q =>
            q.lte("_creationTime", maxCreatedAt),
          )
          .order("desc")
          .paginate(paginationOpts);
      },
    });
    

    You’d get maxCreatedAt once (e.g. via a .first() query) and reuse it so the feed doesn’t keep growing with every new post.

  • Or, if your main concern is UX flashing rather than bandwidth, wrap usePaginatedQuery in useStablePaginatedQuery to ignore intermediate loading states while still being reactive. [Overreacting pagination]

    import { useRef } from "react";
    import { usePaginatedQuery } from "convex/react";
#

export const useStablePaginatedQuery = ((name, ...args) => {
const result = usePaginatedQuery(name as any, ...(args as any));
const stored = useRef(result);

if (result.status !== "LoadingMore") {
  stored.current = result;
}

return stored.current;

}) as typeof usePaginatedQuery;


If you paste your current discover query (server + client), I can point out exactly where it’s causing global invalidations and how to adjust it.
----------------------
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
----------------------
#

@mint crag Kapa.ai is still learning and improving, please let me know how I did by reacting below.

mint crag
#

@fresh lava I like the idea. Right now I have this

const scores = await ctx.db.query('feedScores')
  .withIndex('by_score').order('desc')
  .filter(q => q.eq(q.field('archived'), false))
  .paginate(paginationOpts);

If I add creationTime in the index how to still order by score ?

fresh lavaBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You can keep ordering by score and still use _creationTime in the index; _creationTime is always appended automatically as the last field of every index and acts only as a tie‑breaker. [Sorting indexes; Index method]

So you don’t need to add _creationTime yourself; you just need an index that starts with the fields you want to filter on, then score:

// schema.ts
feedScores: defineTable({
  score: v.number(),
  archived: v.boolean(),
  // ...
}).index("by_archived_score", ["archived", "score"]);

Then query:

const scores = await ctx.db
  .query("feedScores")
  .withIndex("by_archived_score", q =>
    q.eq("archived", false), // restrict range by archived
  )
  .order("desc")             // order by score descending
  .paginate(paginationOpts);

Because the index is on ["archived", "score", "_creationTime"] (the last part is implicit), this query:

  • Filters efficiently by archived = false using the index range. [Querying with indexes]
  • Returns results ordered by archived, then score, then _creationTime; since archived is fixed to false in the range, the effective order is score (with _creationTime only breaking ties). [Sorting indexes]

So you get the same “order by score” behavior you have now, but without the broad .filter that was causing extra invalidations.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@mint crag Kapa.ai is still learning and improving, please let me know how I did by reacting below.

mint crag
#

@fresh lava ok but if I want to add the maxAge as you told me ?

fresh lavaBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You can include a “max age” (cutoff _creationTime) while still ordering by score by:

  1. Putting archived and score in the index (so you can filter by archived and sort by score), and
  2. Using _creationTime as a range bound inside the same index expression.

Remember: _creationTime is automatically appended as the last column of every index and can be used in the range builder after you’ve stepped through earlier index fields in order. [Querying indexes; Sorting with indexes]

1. Schema

// schema.ts
feedScores: defineTable({
  score: v.number(),
  archived: v.boolean(),
  // ...
}).index("by_archived_score", ["archived", "score"]);

This index is effectively on ["archived", "score", "_creationTime"].

2. Query with maxAge

Assume maxAge is a timestamp in ms (e.g. “only items created before this time”):

export const listFeed = query({
  args: {
    paginationOpts: paginationOptsValidator,
    maxAge: v.number(), // cutoff _creationTime
  },
  handler: async (ctx, { paginationOpts, maxAge }) => {
    return await ctx.db
      .query("feedScores")
      .withIndex("by_archived_score", q =>
        q
          .eq("archived", false)
          // now step to the next index field: "score"
          // and use it as a lower/upper bound if you want,
          // OR skip bounds on score and go straight to _creationTime:
          .lte("_creationTime", maxAge),
      )
      .order("desc") // still ordered by score (then _creationTime as tie‑breaker)
      .paginate(paginationOpts);
  },
});

Key points:

#
  • The index range expression must “step through” fields in index order. [IndexRangeBuilder; Querying indexes]
  • Because archived is fixed to false, the effective sort is by score (desc), with _creationTime only breaking ties. [Sorting with indexes]
  • Using .lte("_creationTime", maxAge) in the index expression restricts the range so newer documents don’t even enter the query’s consideration set, which reduces invalidations and bytes read. [Querying indexes]

If you want “only items newer than X” instead, use .gte("_creationTime", minAge) instead of .lte.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@mint crag Kapa.ai is still learning and improving, please let me know how I did by reacting below.

mint crag
#

@fresh lava nop I can't do this when indexing like you did. _creationTime doesn't work

fresh lavaBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You’re right to push back here, and thanks for calling this out.

Two important clarifications based on the docs:

  1. _creationTime is always part of every index, but you don’t list it in the index definition. [Index limits; IndexTiebreakerField]
  2. You can use _creationTime in a withIndex range expression, but only after stepping through earlier index fields in order. [Querying with indexes; IndexRangeBuilder]

However, the order of fields in the index matters for what comparisons are allowed.

Your current index:

.index("by_score", ["score"])

means the index fields are effectively:

["score", "_creationTime"]

So in a withIndex("by_score", q => ...) expression, the allowed order is:

  1. Zero or more .eq("score", ...)
  2. Then bounds on either "score" (if you didn’t use .eq on it) or "_creationTime" (if you did). [IndexRangeBuilder; Querying with indexes]

That’s why this is valid (from the docs, same pattern with channel then _creationTime):

ctx.db
  .query("messages")
  .withIndex("by_channel", q =>
    q
      .eq("channel", channel)
      .gt("_creationTime", Date.now() - 2 * 60000)
      .lt("_creationTime", Date.now() - 60000),
  )
  .collect(); [[Querying with indexes](<https://docs.convex.dev/database/reading-data/indexes#querying-documents-using-indexes>)]

Translating to your case:

#

1. Add archived into the index

You want to filter by archived and sort by score, so define:

// schema
feedScores: defineTable({
  score: v.number(),
  archived: v.boolean(),
  // ...
}).index("by_archived_score", ["archived", "score"]);

This index is effectively on:

["archived", "score", "_creationTime"]

2. What you can and can’t do in withIndex

With that index, the allowed sequence is:

  • .eq("archived", ...) (first field)
  • Then either:
    • .eq("score", ...) and then bounds on _creationTime, or
    • Bounds on "score" (gt/gte/lt/lte) and stop there

You cannot jump straight from archived to _creationTime without touching score, because you must “step through fields in index order.” [IndexRangeBuilder; Querying with indexes]

So this is invalid:

// ❌ not allowed: skip "score"
.withIndex("by_archived_score", q =>
  q.eq("archived", false).lte("_creationTime", maxAge),
);

and that’s what you’re running into.

3. Options to combine score ordering + max age

Given the constraints above, you have a few realistic patterns:

Option A: Use index for archived + score, and do age as a post-filter

Keep your index purely for archived + score, and filter by _creationTime with .filter. This doesn’t reduce invalidations from new docs in the range, but it keeps ordering by score and bounds the page size via pagination:

const scores = await ctx.db
  .query("feedScores")
  .withIndex("by_archived_score", q =>
    q.eq("archived", false),
  )
  .order("desc")
  .filter(q => q.lte(q.field("_creationTime"), maxAge))
  .paginate(paginationOpts);
#

This is the simplest “maxAge + order by score” combo that the current index rules support.

Option B: Index by time first, then score

If “max age” is more important than “perfect global score ordering,” you can flip the index:

// schema
feedScores: defineTable({
  score: v.number(),
  archived: v.boolean(),
}).index("by_archived_time", ["archived", "_creationTime"]);

Now the index fields are:

["archived", "_creationTime"]

You can do:

const scores = await ctx.db
  .query("feedScores")
  .withIndex("by_archived_time", q =>
    q
      .eq("archived", false)
      .lte("_creationTime", maxAge),
  )
  .order("desc") // now effectively ordering by _creationTime, not score
  .paginate(paginationOpts);

You’d then sort by score on the client or in memory if needed, but only within the page.

Option C: More complex pattern (index skip scan / streams)

For truly “SQL‑style” conditions like WHERE score > X AND _creationTime > Y with a single index, Convex suggests a more advanced pattern using multiple queries or QueryStreams (index skip scan). [Filter out of order]

That’s overkill for most feeds, but it’s the documented way to combine conditions that don’t line up with a single contiguous index range.


So: you’re correct that you can’t just “add _creationTime to the index and then do .lte("_creationTime", maxAge) after archived” with ["archived", "score"]—the type system and runtime rules forbid skipping score. The safe options are:

#
  • Use ["archived", "score"] and apply maxAge via .filter, or
  • Use a time‑first index like ["archived", "_creationTime"] and accept that ordering is primarily by time, not score, or
  • Use a more advanced multi‑query / streams pattern if you need both to be fully index‑driven.

If you tell me which of these trade‑offs you prefer (strict score ordering vs strict age cutoff vs simplicity), I can sketch the exact query + schema for that choice.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@mint crag Kapa.ai is still learning and improving, please let me know how I did by reacting below.

mint crag
#

@fresh lava Looks like we can't index filter with more than 3 fields...

fresh lavaBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You actually can index and filter on more than 3 fields in Convex; the limit is much higher.

From the docs:

  • An index can contain up to 16 fields (and _creationTime is automatically appended and counts toward that limit).
  • You can define up to 32 indexes per table. [Index limits]

The main constraints are:

  1. Index definition limit
    You can list up to 16 fields in .index("name", ["field1", "field2", ...]) (not including _creationTime, which Convex adds automatically but still counts toward the 16). [Index limits]

  2. How you can filter with withIndex
    In a withIndex("my_index", q => ...) expression, you must:

    • Step through index fields in order.
    • First do zero or more .eq("field", value) calls, starting from the first index field.
    • Then optionally add a lower bound (.gt / .gte) and/or upper bound (.lt / .lte) on the next field. [Querying with indexes]

    You can’t skip fields or compare them out of order. That’s usually what makes it feel like you’re limited, because some combinations of conditions don’t line up with a single contiguous index range.

So if you have an index like:

.index("by_a_b_c_d", ["a", "b", "c", "d"])

valid withIndex patterns include:

// eq on a, then range on b
.withIndex("by_a_b_c_d", q => q.eq("a", aVal).gt("b", bMin));

// eq on a, b, c, then range on d
.withIndex("by_a_b_c_d", q =>
  q.eq("a", aVal).eq("b", bVal).eq("c", cVal).lte("d", dMax),
);

but not:

// ❌ skip b and c, jump to d
.withIndex("by_a_b_c_d", q => q.eq("a", aVal).lt("d", dMax));
#

If you describe the exact fields you want to filter/sort on, I can suggest an index layout that fits within these rules.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@mint crag Kapa.ai is still learning and improving, please let me know how I did by reacting below.

mint crag
#

@fresh lava then WRITE THE QUERY FOR archived score & _creationTime if you're that smart

fresh lavaBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You can absolutely index on archived and score, and then use _creationTime as the automatic tiebreaker while still paginating efficiently. The key is:

  • Put archived and score in the index.
  • Use withIndex to constrain archived.
  • Use .order("desc") to sort by score (and _creationTime as a tiebreaker). [Sorting indexes; Querying with indexes]

You cannot put _creationTime into the withIndex range expression for this index without stepping through score first, so the “maxAge in the index range” version is not legal here. Instead, you keep _creationTime as the implicit last field and, if you need a max age, you apply it with .filter.

1. Schema

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  feedScores: defineTable({
    score: v.number(),
    archived: v.boolean(),
    // ...
  }).index("by_archived_score", ["archived", "score"]),
});

This index is effectively on ["archived", "score", "_creationTime"]. [Indexes; Sorting indexes]

2. Query: archived + score + optional maxAge

// convex/feed.ts
import { query } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
#

export const listFeed = query({
args: {
paginationOpts: paginationOptsValidator,
maxAge: v.optional(v.number()), // timestamp in ms, optional
},
handler: async (ctx, { paginationOpts, maxAge }) => {
let q = ctx.db
.query("feedScores")
.withIndex("by_archived_score", (q) =>
q.eq("archived", false),
)
.order("desc"); // ordered by score desc, then _creationTime desc

if (maxAge !== undefined) {
  // apply age cutoff as a filter, since we can't jump to _creationTime
  q = q.filter((q) =>
    q.lte(q.field("_creationTime"), maxAge),
  );
}

return await q.paginate(paginationOpts);

},
});


Why this is valid and efficient:

- `withIndex("by_archived_score", q => q.eq("archived", false))` uses the index to jump directly to the `archived === false` range. [[Querying with indexes](<https://docs.convex.dev/database/reading-data/indexes#querying-documents-using-indexes>)]
- `.order("desc")` on that index means results are sorted by `score` descending (and `_creationTime` as a tiebreaker). [[Sorting indexes](<https://docs.convex.dev/database/reading-data/indexes#sorting-with-indexes>)]
- `maxAge` is applied via `.filter` because the index rules don’t allow you to skip `score` and compare `_creationTime` directly for this index layout. [[IndexRangeBuilder](<https://docs.convex.dev/api/interfaces/server.IndexRangeBuilder>)]

If you want a version where `maxAge` *is* part of the index range (not a filter), you’d need an index that puts `_creationTime` before `score`, e.g. `["archived", "_creationTime"]`, and then you’d be ordering primarily by time, not score. That’s a different trade‑off; the code above keeps “order by score” as you asked.
----------------------
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
----------------------
#

@mint crag Kapa.ai is still learning and improving, please let me know how I did by reacting below.

mint crag
#

@fresh lava you're filtering dude ??? you're not even using indexes...

fresh lavaBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You’re right to call this out: if you care about both:

  • using an index (no full-table scan), and
  • constraining by _creationTime,

then the index has to be laid out so that _creationTime is in a position where it can be used in the withIndex range expression. You can’t just “tack on” a time condition after archived when the index is ["archived", "score"] because you must step through fields in index order. [Querying indexes]

So there are two index‑driven options, depending on what you want to prioritize.


Option 1: Index by archived + _creationTime (time‑first), then sort by score in memory

If “max age” is important and you want the age cutoff to be in the index, you need _creationTime to be the next field after archived:

// schema.ts
feedScores: defineTable({
  score: v.number(),
  archived: v.boolean(),
  // ...
}).index("by_archived_time", ["archived", "_creationTime"]);

Index fields are effectively: ["archived", "_creationTime"] (plus implicit _creationTime tiebreaker, but that’s the same field). [Sorting with indexes]

Now you can use both archived and _creationTime in the withIndex range:

// convex/feed.ts
export const listFeed = query({
  args: {
    paginationOpts: paginationOptsValidator,
    maxAge: v.number(), // only items with _creationTime <= maxAge
  },
  handler: async (ctx, { paginationOpts, maxAge }) => {
    const results = await ctx.db
      .query("feedScores")
      .withIndex("by_archived_time", q =>
        q
          .eq("archived", false)
          .lte("_creationTime", maxAge),
      )
      .order("desc") // now ordering by _creationTime desc
      .paginate(paginationOpts);
#

// If you must show by score, sort the page in memory:
return {
...results,
page: results.page.sort((a, b) => b.score - a.score),
};
},
});


Here:

- The **index** is used to jump to `archived === false` and `_creationTime <= maxAge`. [[Querying indexes](<https://docs.convex.dev/database/reading-data/indexes#querying-documents-using-indexes>)]
- The only “non‑indexed” work is sorting the *page* by `score`, which is fine if each page is small (tens/hundreds of docs). [[Ordering docs](<https://docs.convex.dev/database/reading-data#ordering>)]

No `.filter` on `_creationTime` — the age cutoff is in the index range.

---

## Option 2: Index by `archived` + `score` (score‑first), accept that age is a post‑filter

If you insist on **global order by score** from the DB, the index has to be `["archived", "score"]`:

```ts
feedScores: defineTable({
  score: v.number(),
  archived: v.boolean(),
}).index("by_archived_score", ["archived", "score"]);

Index fields: ["archived", "score", "_creationTime"]. [Sorting with indexes]

With that layout, the rules say:

  • You can .eq("archived", ...).
  • Then you can put bounds on score or (if you also .eq("score", ...)) on _creationTime.
  • You cannot jump straight from archived to _creationTime in the index expression. [Querying indexes]

So there is no legal withIndex expression that uses archived, arbitrary score ordering, and _creationTime as a range bound all at once. That’s why any maxAge condition has to be a .filter in this layout.

If you want to see that pattern explicitly:

#
const scores = await ctx.db
  .query("feedScores")
  .withIndex("by_archived_score", q =>
    q.eq("archived", false),
  )
  .order("desc") // ordered by score desc (then _creationTime)
  .paginate(paginationOpts);

This does use the index; the only non‑indexed part (if you add age) would be a .filter on _creationTime. [Sorting with indexes]


Why there isn’t a “perfect” single‑index solution

Convex’s withIndex is intentionally strict: it only lets you describe ranges that are contiguous in the index. [Querying indexes]

Your desired conditions:

  • archived = false
  • score arbitrary (for ordering)
  • _creationTime <= maxAge

don’t line up as a single contiguous range on ["archived", "score", "_creationTime"] unless you’re also constraining score (e.g. score >= X). That’s why the docs show more complex “index skip scan” patterns when you need out‑of‑order conditions. [Filter out of order]

So the honest answer, grounded in the docs, is:

  • If you want maxAge in the index: index ["archived", "_creationTime"], then sort the page by score in memory.
  • If you want true index‑level order by score: index ["archived", "score"] and accept that _creationTime must be a .filter, not part of the index range.

There isn’t a third option that gives you all three (archived, global score order, and maxAge as an index bound) in a single simple withIndex call.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@mint crag Kapa.ai is still learning and improving, please let me know how I did by reacting below.

mint crag
#

@fresh lava
What about this

const scores = await ctx.db.query('feedScores')
  .withIndex('by_archived_score', (q) =>
    q.eq('archived', false).lte('createdAt', maxAge))
  .order('desc')
  .paginate(paginationOpts);

createdAt: v.number(), // timestamp in ms
userId: v.id('users'),
archived: v.boolean(),
})
.index("by_archived_score", ["archived", "createdAt", "score"])

fresh lavaBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

That schema + query is valid and does what you want, with one important nuance about ordering.

1. Your index and query are consistent

Schema:

feedScores: defineTable({
  createdAt: v.number(), // timestamp in ms
  userId: v.id("users"),
  archived: v.boolean(),
  score: v.number(),
}).index("by_archived_score", ["archived", "createdAt", "score"]);

Index fields (in order) are effectively:

["archived", "createdAt", "score", "_creationTime"]

Query:

const scores = await ctx.db
  .query("feedScores")
  .withIndex("by_archived_score", q =>
    q.eq("archived", false).lte("createdAt", maxAge),
  )
  .order("desc")
  .paginate(paginationOpts);

This matches Convex’s rules:

  • You step through index fields in order: first archived with .eq, then createdAt with .lte. [Querying indexes; IndexRangeBuilder]
  • The range is: archived === false && createdAt <= maxAge.
  • Because you’re using withIndex, Convex only scans that contiguous range in the index. [Querying indexes]

So this does use the index for both archived and createdAt (your “max age” field).

2. What .order("desc") means here

When you use withIndex, results are ordered by the index columns, in order:

  1. archived
  2. createdAt
  3. score
  4. _creationTime (implicit tie‑breaker) [Sorting with indexes]

.order("desc") just flips that whole index order to descending; it does not change the primary sort key to score. So within archived === false, you’re effectively getting:

#
  • createdAt descending,
  • then score as a tie‑breaker,
  • then _creationTime as a final tie‑breaker.

If that’s acceptable (time‑first, score‑second), your setup is good and fully index‑driven.

If you instead want score‑first ordering, you’d need score earlier in the index (e.g. ["archived", "score", "createdAt"]), but then you can’t use createdAt as a range bound without also constraining score first, due to the “step through fields in order” rule. [Querying indexes]

So:

  • For “archived + maxAge, ordered by time” → your current index/query is correct.
  • For “archived + maxAge, ordered by score” → you’d need to either:
    • change the index to be score‑first and apply maxAge via .filter, or
    • keep your index as‑is and sort the page by score in memory after pagination. [Ordering]

But as written, your code is a valid, index‑using query for archived === false && createdAt <= maxAge, ordered primarily by createdAt (desc).

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@mint crag Kapa.ai is still learning and improving, please let me know how I did by reacting below.

mint crag
#

@fresh lava I can only add more index sorting when using EQ when using LTE or GTE, it doesnt return a functino to sort by an other field

fresh lavaBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You’re running into exactly how withIndex is supposed to work in Convex.

From the docs, an index range expression is always:

  1. Zero or more .eq(...) on index fields, in order.
  2. Optionally one lower bound (.gt / .gte) on the next field.
  3. Optionally one upper bound (.lt / .lte) on that same field. [Querying indexes; IndexRangeBuilder]

Once you call .gte/.lte on a field, the range is “closed” on that field; the builder type doesn’t let you move on to another index field. That’s why you “can only add more index sorting when using eq”: only .eq lets you step to the next field; bounds (gt/gte/lt/lte) must be on the next field and are the last thing you can do in the range. [IndexRangeBuilder]

Example from the docs:

// index: ["channel", "_creationTime"]
const messages = await ctx.db
  .query("messages")
  .withIndex("by_channel", (q) =>
    q
      .eq("channel", channel)
      .gt("_creationTime", Date.now() - 2 * 60000)
      .lt("_creationTime", Date.now() - 60000),
  )
  .collect();

Here:

  • .eq("channel", ...) → first index field.
  • .gt / .lt on _creationTime → second field, and you can’t then go on to a third. [Querying indexes]

So with an index like:

.index("by_archived_createdAt_score", ["archived", "createdAt", "score"])

the legal patterns are:

  • q.eq("archived", false)
  • q.eq("archived", false).eq("createdAt", someDay)
  • q.eq("archived", false).gte("createdAt", minAge) (and maybe .lt("createdAt", maxAge))
#

…but you cannot then add a bound on score after a gte/lte on createdAt. The type system prevents that because it wouldn’t be a single contiguous index range. [IndexRangeBuilder]

To combine more conditions than the index order allows, the docs suggest:

  • Move some conditions to .filter (post‑filter, not index‑driven). [Indexing multiple fields]
  • Or design a different index where the field you want to bound with gte/lte comes after all the eq fields you need. [Indexing multiple fields]
  • For more complex “out of order” conditions, use multi‑query / streams patterns (index skip scan). [Filter out of order]

So your observation is correct: after a .gte/.lte in withIndex, you can’t “keep chaining” to another index field; that’s by design, not a bug.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: