#Convex

37 messages · Page 1 of 1 (latest)

weary terrace
#

What is best practice for convex?
I have posts with comments, likes, bookmarks and also the state if the user liked or bookmark it. How should I split the api

vivid lionBOT
#

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

#

For posts with comments, likes, bookmarks, and per-user state, there are two separate questions:

  1. How to split the data model
  2. How to split the Convex API / code

Below is what’s recommended in the docs and community threads.


1. Data modeling: tables & relationships

Convex encourages using separate tables for logical object types and linking them with Ids instead of nesting everything in one big document.[Other recommendations; Document IDs]

A common split for your case:

  • posts
  • comments
  • likes
  • bookmarks

Example relationships:

// post
await ctx.db.insert("posts", {
  authorId: user._id,
  title,
  body,
});

// comment
await ctx.db.insert("comments", {
  postId,         // Id<"posts">
  authorId,       // Id<"users">
  body,
});

// like
await ctx.db.insert("likes", {
  postId,         // Id<"posts">
  userId,         // Id<"users">
});

// bookmark
await ctx.db.insert("bookmarks", {
  postId,         // Id<"posts">
  userId,         // Id<"users">
});

This matches the guidance to separate logical types into tables and use Ids as references.[Other recommendations; Document IDs]

For counts (like total likes/bookmarks per post), Convex recommends either:

  • Keeping a denormalized counter on posts updated in your mutations, or
  • Using the Convex Aggregate component for more complex aggregates.[Why no SELECT/COUNT]

2. API design & code organization

a. Separate API layer vs model layer

#

Best practice is:

  • Put most logic in plain TypeScript helper functions under something like convex/model/.
  • Keep query, mutation, action wrappers very thin and focused on validation/auth.[Use helper functions; Example 3]

Pattern:

convex/
  model/
    posts.ts      // helpers: loadPostWithCounts, ensureCanViewPost, etc.
    comments.ts
    reactions.ts  // likes + bookmarks helpers
  posts.ts        // thin API wrappers
  comments.ts
  reactions.ts

Example from docs: helpers in convex/model/... implement logic (auth checks, loading related docs), while the public query/mutation just forwards to them.[Example 3]

This separation also scales better in large codebases and matches what’s suggested in Discord about having a “db/model layer” and an “api layer” for feature code.[Larger codebase thread]

b. Public vs internal functions

For jobs (ctx.scheduler, crons) and ctx.runQuery/Mutation/Action, only call internal.* functions, and keep public api.* as your external surface with stricter validation/auth.[Only schedule internal]

Pattern:

  • posts.ts exports:
    • getPostWithMeta (public query – validates auth, calls model helper)
    • likePost / bookmarkPost (public mutations)
    • likePostInternal / bookmarkPostInternal (internalMutation for jobs, if needed)

c. “Post with user state” API shape

Typically you want a single query per view:

#
// posts.ts (public API)
export const getPostWithUserState = query({
  args: { postId: v.id("posts") },
  handler: async (ctx, { postId }) => {
    return await PostsModel.getPostWithUserState(ctx, { postId });
  },
});

In convex/model/posts.ts:

  • Load the post
  • Load denormalized counts from posts (if you store them)
  • Or compute counts via likes / bookmarks in a bounded way:
  • Check if the current user has a like/bookmark for that post (small, indexed queries).

Return a combined object like:

{
  post,
  likeCount,
  bookmarkCount,
  viewerHasLiked: boolean,
  viewerHasBookmarked: boolean,
}

Then the frontend can just call one Convex query per screen, following the “use a query for nearly every app read” guidance.[Zen of Convex]


3. Performance notes

#
  • For lists of posts (feeds), paginate or at least .take(N); avoid unbounded .collect().[Queries that scale; Best-practices db]
  • Keep each query/mutation working on < a few hundred records and finishing fast.[Zen of Convex]
  • If you store huge text or blobs on posts, consider splitting into a postBodies / bigTable to reduce read size, as suggested for large fields.[No SELECT/COUNT solutions]

If you describe a specific UI (e.g. “post detail screen with 50 comments and infinite scroll”), I can map that to a concrete set of query/mutation signatures within this pattern.

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

#

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

weary terrace
#

@vivid lion what about caching?

vivid lionBOT
#

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

#

Convex already gives you a lot “for free”; the main decisions are about what you read inside each query so caching is effective and not over‑invalidated.

1. How Convex caching works (for your posts API)

  • Every query is:
  • In authenticated apps, that cache is effectively per user/session if the query reads ctx.db.auth. Shared caching between users only happens when:
    • The query does not read auth and
    • It’s invoked via ctx.runQuery (e.g. from an action).[Caching woes thread]

So for your “post with comments/likes/bookmarks + user state”:

  • A query like getPostWithUserState(postId) that:
    • Reads the post + global counts
    • Reads viewer-specific like/bookmark rows
      will be cached per viewer, but that’s usually fine.

2. Structuring data to get good caching

The key is: avoid queries depending on noisy / frequently updated fields unless they actually need them, because that causes constant invalidation.[Queries that scale; Solution example]

Patterns you can reuse:

  1. Split frequently-changing state into separate tables

    Example from heartbeats:

#
  • Instead of users.lastSeen causing every user-related query to invalidate, they moved lastSeen to a separate heartbeats table and only queries that care about online status read it.[Solution example]

    Applied to you:

    • Keep relatively stable data (title, body, author) on posts.
    • Put very chatty data (e.g. presence, read receipts, fast-changing metrics) into separate tables so only views that need them subscribe to them.
  1. Denormalized counts

    For like/bookmark counts, instead of counting on every query:

    • Maintain likesCount / bookmarksCount on posts and update those in like/bookmark mutations.[No SELECT/COUNT solutions]
    • Then your post/feed queries just read one posts document each and get cheap cache hits.

    For more complex leaderboards or grouped counts, use the Aggregate component (pre-computed counts/sums in a denormalized structure).[Aggregate component]

  2. Pagination and limits

    Don’t .collect() all comments/likes for a post; paginate or limit:

    • Keeps each query under “a few hundred records” and <100ms, which is the recommended shape for reactive queries.[Zen of Convex; Queries that scale]
    • Reduces the set of documents each query depends on, so invalidation is more targeted.

3. When should you worry about caching?

Convex’s philosophy: don’t design around caching first.

#
  • “Don’t think about caching until you’ve proven you have no other choice.”[What’s a dev to do?]
  • Because queries are deterministic, Convex can cache and invalidate automatically; your main job is to:

If, later, you do find a heavy aggregation that’s recomputed too often:

  • Precompute it via a mutation/action on a schedule and store the result in a summary/aggregate table; queries then just read that table. This “materialized view” pattern is explicitly recommended for expensive time‑series aggregations.[Caching expensive query thread; Representation section]

4. Client-side query caching helpers

For UI flows like feeds/infinite scroll (posts):

  • Convex already caches query results on the server, but navigation can trigger fresh subscriptions.
  • If you want “stickier” client-side caching across navigation, use the ConvexQueryCacheProvider helper, which keeps previous query results in the client (e.g. infinite feeds, chats).[Magic caching; useInfinite/usePaginated thread]

#

If you describe your main views (e.g. “feed of posts with like counts and whether I liked them”), I can suggest concrete query signatures and where to place per-user vs shared data so caching and invalidation work in your favor.

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

#

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

weary terrace
#

@vivid lion my main view is a feed with likes, comments, bookmarks and only for authenticated users

vivid lionBOT
#

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

#

For an authenticated-only feed with likes, comments, and bookmarks, you want:

  • One main feed query per user.
  • Data modeled in separate tables (posts, comments, likes, bookmarks).
  • Queries kept small, indexed, and paginated so Convex’s realtime caching works well.[Other recommendations; Queries that scale]

Below is how to structure this in terms of API shape and caching behavior.


1. Data model (tables)

Use separate tables and link with Ids, as recommended.[Other recommendations]

  • posts – core post data (+ denormalized counters).
  • comments – each comment with postId.
  • likes(postId, userId) rows.
  • bookmarks(postId, userId) rows.

Keep counts (likes/comments/bookmarks) denormalized on posts and maintain them in mutations, instead of counting every time.[Why no SELECT/COUNT]


2. Main feed query: what it should return

Have one query like feed.getForCurrentUser that:

  • Checks auth with ctx.auth.getUserIdentity() and throws if unauthenticated.[Better Auth authorization]
  • Returns a page of posts (paginated) with:
    • Core post fields.
    • Denormalized counts (likesCount, commentsCount, bookmarksCount).
    • Booleans for this viewer: viewerHasLiked, viewerHasBookmarked.
#

You can compute viewerHasLiked/viewerHasBookmarked by querying likes/bookmarks for this user and post; just make sure those queries are bounded (index + limit) so the feed query stays under “a few hundred records.”[Zen of Convex; Queries that scale]

Keep the per-user state separate from the post document so only the views that care about it read those rows, which leads to more targeted invalidation.[Queries that scale – caching]


3. Caching & realtime for this feed

Convex queries are:

  • Cached and reactive – whenever any document they read changes, the query is rerun and the client updates.[Realtime; Queries caching]
  • For authenticated users, the cache key effectively includes auth, so feed.getForCurrentUser is cached per user.

For this feed:

  • When someone likes/bookmarks/comments on a post:
    • Your like/bookmark/comment mutation should update:
      • The likes/bookmarks/comments table row.
      • The denormalized counter on the corresponding posts row.[Why no SELECT/COUNT]
    • Any user subscribed to feed.getForCurrentUser that reads those documents will get updated automatically, without you managing cache invalidation.[Presence caching section]

To keep this cheap:

#

4. Frontend usage (authenticated-only feed)

On the client:

  • Use Convex auth state (useConvexAuth, <Authenticated> / <Unauthenticated>) to only run the feed query when the user is logged in.[Better Auth authorization; Auth best practices]
  • Optionally wrap in a helper like useAuthenticatedQueryWithStatus so the query is automatically skipped until isAuthenticated is true.[Auth query hook]

Example pattern from the docs (simplified):

// Client
const { isLoading, isAuthenticated } = useConvexAuth();
const feedPage = useQuery(
  api.feed.getForCurrentUser,
  isAuthenticated ? { cursor } : "skip"
);

This ensures:

  • No unauthenticated access.
  • No unnecessary backend calls before Convex has validated the user’s token.[Better Auth authorization]

#

If you want, describe how you want pagination to work (infinite scroll vs “Load more”), and I can outline how to shape the feed query itself (arguments and return type) within these constraints.

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

#

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

weary terrace
#

@vivid lion I split the queries for my posts. UserInteractions (Liked, Bookmarked), comments, post

vivid lionBOT
#

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

#

Splitting them is reasonable; the main guideline is how you split and how the client calls them.

From the docs/Stack posts, the recommended pattern is:

  1. One primary query per view
    For your feed view, aim for a single “main” query that returns everything the UI needs for each post, rather than always composing multiple queries on the client. [Zen of Convex]
    But that main query can internally call helpers that hit different tables.

  2. Factor logic into helpers / “model layer”
    Put the post, comments, and user‑interaction loading logic into reusable helper functions (e.g. Posts.loadFeedPosts, Interactions.loadForUserAndPosts, Comments.loadCountsForPosts) in convex/model/*, and have thin query wrappers that call them. [Best practices – helper functions]
    This keeps things maintainable even if you later change how you aggregate data.

  3. When to keep them separate vs combine

#
  • Keep separate queries when:
    • Different UI surfaces need different subsets (e.g. a lightweight feed that only needs post + counts, and a detail page that needs full comments).
      In that case you might have:
      • feed.getPostsPage (posts + counts + viewer like/bookmark flags)
      • comments.getForPostPaginated
      • interactions.getForPost (only on detail view)
    • Combine into one query when:
      • The same view always needs all three (post + user interactions + some comments), and the amount of data per post is bounded (a page of posts, and maybe only first N comments per post). This matches the style in the relationship helpers article where they join posts, comments, and categories in a single query with helper functions. [Joining data with functions]
  1. Caching / invalidation tradeoff

    • If you put user interactions and comments into the same query as posts, that query’s result will be invalidated whenever any of those underlying docs change (like or new comment), which is usually what you want for a feed. [Queries that scale – caching]
    • If some parts change much more frequently than others (e.g. presence, typing indicators), consider separate tables + separate queries to avoid over‑invalidating the main feed. This is the same idea as splitting heartbeats from users to reduce cache churn. [Heartbeat example]
  2. Keep each query small & paginated

    Whatever split you choose, each query should:

#

Practical suggestion for your case:

  • For the feed screen, expose one query like feed.getPage that:

  • For post detail, you can either:

    • Reuse the same feed.getPage entry plus a separate comments.getForPostPaginated, or
    • Have a dedicated posts.getDetail that assembles post + interactions + first page of comments.

Your current separation (posts / interactions / comments) is fine; just try to avoid making the client manually orchestrate 3–4 queries for the same view if you can easily compose them server‑side into one query per screen.

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

#

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

weary terrace
#

@vivid lion so if I just use one query for everything is it better?

vivid lionBOT
#

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

#

Using one query per view (per screen) is generally the best practice in Convex, as long as that query stays small and efficient.

What Convex recommends

  • Convex encourages you to “use a query for nearly every app read” and keep those queries light & fast (working with at most a few hundred records, finishing quickly). [Zen of Convex]
  • You’re expected to join related data in code (posts + comments + categories, etc.) inside a single query using helper functions, instead of many little queries from the client. [Joining data with functions]
  • For large result sets (feeds, comments), you should paginate or limit, not .collect() everything. [Queries that scale – pagination; Only use collect with small results]

So for your feed of posts including:

  • post data
  • counts (likes/comments/bookmarks)
  • per-user interactions (liked/bookmarked)
  • maybe a small number of comments per post

…it’s usually better to expose one query like feed.getPage that returns everything the feed UI needs, rather than making the client call 3–4 separate queries and merge them.

When “one query” is not better

You should split into multiple queries when:

#
  • The combined data would be too big or exceed read limits (e.g. all comments for many posts at once) → then paginate separately. [Queries that scale – pagination]
  • Some data changes much more frequently than the rest and would cause unnecessary cache invalidation for the whole view (similar to splitting heartbeats from users). [Queries that scale – caching]

Practical takeaway for your feed

For your main authenticated feed:

  • Yes: prefer a single paginated query that returns posts + counts + viewerHasLiked / viewerHasBookmarked.
  • Maybe separate: full comment threads, or very fast‑changing data, into their own paginated queries.

If you describe exactly what each feed item needs to render (e.g. “first 2 comments only” vs “all comments”), I can say more concretely whether it should go into that single query or be a separate one.

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

#

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

weary terrace
#

@vivid lion how should i get the like count?

vivid lionBOT
#

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

#

For a like count on posts, Convex’s recommended pattern is to denormalize the count instead of running COUNT-style queries every time.

There are three main options in the docs:


1. Store a likesCount field on posts (simple & common)

Add a numeric field and update it in your like/unlike mutations:

// schema.ts
posts: defineTable({
  body: v.string(),
  likesCount: v.number(),
}),
likes: defineTable({
  postId: v.id("posts"),
  userId: v.id("users"),
}),

In your mutation that inserts a like:

export const likePost = mutation({
  args: {
    postId: v.id("posts"),
    userId: v.id("users"),
  },
  handler: async (ctx, args) => {
    await ctx.db.insert("likes", {
      postId: args.postId,
      userId: args.userId,
    });

    const post = await ctx.db.get(args.postId);
    if (!post) throw new Error("Post not found");

    await ctx.db.patch(args.postId, { likesCount: post.likesCount + 1 });
  },
});

This is exactly the pattern suggested for “Why doesn’t Convex have COUNT?” — keep a count on the main document and update it whenever you insert/delete the related rows, so reads are cheap.[Why no COUNT]

You then just read post.likesCount in your feed query.


2. Use a trigger to keep a count table in sync

If you don’t want to embed the count on posts, you can have a separate postLikeCounts table and maintain it via a trigger on the likes table:

  • The docs’ trigger example shows keeping a userCount document updated when users is inserted/deleted.[Denormalizing a count]

Pattern:

#
triggers.register("likes", async (ctx, change) => {
  const { postId } = change.newDoc ?? change.oldDoc!;
  const countDoc = (await ctx.db
    .query("postLikeCounts")
    .withIndex("by_post", q => q.eq("postId", postId))
    .unique())!;

  if (change.operation === "insert") {
    await ctx.db.patch(countDoc._id, { count: countDoc.count + 1 });
  } else if (change.operation === "delete") {
    await ctx.db.patch(countDoc._id, { count: countDoc.count - 1 });
  }
});

Then your feed query joins posts with postLikeCounts.count.


3. Use the Sharded Counter / Aggregate components (for high write throughput)

For very hot like counts where many users may like in parallel:

  • Sharded Counter: efficient high‑throughput counters, using many shards per key.[Sharded Counter; Sharding the counter]

    • You’d have a counter per postId, increment in like mutation, and read via count in your query.
  • Aggregate component: denormalized counts/sums over keys, supports counts and more complex aggregations.[Aggregate – what for]

Trusted staff explicitly suggest Sharded Counter or Aggregate as options for “high efficiency counting.”[Discord filtering thread]


What to choose for your feed

For a typical social-style feed:

  • Use option 1: likesCount on posts updated in like/unlike mutations.
  • If a single post may get very high like write throughput (and you care about write latency), consider swapping the field out for a Sharded Counter keyed by postId.
#

All of these approaches avoid scanning the likes table on every query and match Convex’s guidance on counts and denormalization.

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