All posts

Why a two-user Convex chat app read tens of MB a day

June 13, 2026·6 min readconvexperformancedatabasereact

I was staring at my Convex dashboard, confused. Convex is the reactive backend I use for a chat app: it stores the data and, the part that matters here, it keeps your queries live, so the UI updates the instant the data changes. The dashboard has a meter called "Database Bandwidth," and for two users, me on a dev account and me on a prod account, poking the app for maybe half an hour, it was reading tens of megabytes a day.

That made no sense to me. It's a chat app. The actual messages were a few hundred kilobytes, tops. Where were the megabytes coming from? I assumed I'd stored something huge by accident. I hadn't. The data really was tiny. The bandwidth was real. Both things were true at once, and the gap between them is the whole story.

The one sentence that explains everything

Here's the thing nobody put plainly for me, so let me put it plainly for you. In Convex, "Database Bandwidth" is the bytes of documents your functions read and write per execution. And a reactive query re-executes every single time any document in its result changes.

Unpack "reactive query," because it's the load-bearing idea. When your React app calls useQuery(...), it isn't a one-shot fetch. It's a subscription. Convex runs the query on the server, sends you the result, and then keeps watching. The moment any document that query touched changes, Convex re-runs the whole query and pushes the fresh result to every client subscribed to it. That's what makes the UI feel live, and it's wonderful, right up until it's your bill.

So bandwidth is not "the size of your data." It's:

bandwidth  =  size of the query result  ×  how many times it re-runs

That multiplier on the right is where megabytes come from. A query that returns 200 KB and re-runs 150 times because the data kept changing has moved 30 MB, even though you only ever stored 200 KB. Drag the inputs here and watch a few KB of data turn into a daily bill:

Once I saw it as a multiplication, the question stopped being "what did I store" and became "which queries re-read a lot of data, a lot of times." That reframing found every problem.

Trap 1: a plain query re-reads the whole list (pagination is the knob)

My message list was a plain useQuery that returned the conversation's messages. Every time a new message arrived, that query re-ran and re-shipped the entire list. Message 50 lands, and Convex re-reads and re-sends messages 1 through 50. Message 51 lands, it re-sends 1 through 51. The cost of one new message grows with how long the conversation already is.

The fix is Convex's usePaginatedQuery, and it's worth understanding why it works, because it's the single most useful thing I learned here. Pagination loads the list in pages, and each page is its own separate subscription. When a new message lands at the top, only the newest page re-runs. The older pages you already loaded just sit there, untouched, costing nothing. Press the button on both modes:

This reframed pagination for me completely. I'd always thought of it as a UI nicety, load more as you scroll. In Convex it's the actual mechanism for "only re-read what changed." There's no separate incremental-update knob you're missing. Pagination is the knob. If a list can grow and it's reactive, paginate it.

Trap 2: streaming an LLM reply was secretly quadratic

This was the sneaky one. When the model streams a reply token by token, I was saving progress by writing the accumulated text back to a document on every flush. Token 1: write "Hi". Token 2: write "Hi there". Token 3: write "Hi there friend". Each write carries the whole string so far.

Add that up. Writing 1 token, then 2, then 3, all the way to N, is 1 + 2 + 3 + ... + N, which is N²/2. That's quadratic: double the reply length and you quadruple the bytes. A reply that's a few KB of final text can write tens of KB getting there, and that's before the reactive read side re-ships it to the client on every flush too. Drag the token count and watch the two approaches split apart:

The fix is to stop rewriting the whole buffer. Append only the new piece each flush (or stream the text over a separate channel and write the document once at the end). Same final message, linear instead of quadratic. The bigger lesson: anything that rewrites a growing value inside a loop is a quadratic trap waiting to happen, and reactivity multiplies the damage because every rewrite is also a re-read for every subscriber.

Trap 3: .collect() reads the entire table, every call

A couple of my server functions did something like this to count or check usage:

const all = await ctx.db
  .query("messages")
  .withIndex("by_conversation", (q) => q.eq("conversationId", id))
  .collect();

.collect() pulls every matching row into an array. If a function like a daily-usage check or a rate-limiter runs on every single message and calls .collect() over a whole conversation, it re-reads the entire conversation every time someone sends a message. That read scales with table size, on the hot path.

Two fixes, depending on what you actually need:

  • If you only need the recent ones, bound the query: .order("desc").take(25) reads at most 25 rows instead of all of them. Bounded and linear, not "everything, forever."
  • If you only need a count or a sum, denormalize it: keep a running counter on a parent document and update it on write, so you never re-read the children just to count them.

The rule I took away: .collect() on a hot path is a smell. If it can run often, bound it or precompute it.

Trap 4: an unstable argument silently re-subscribes

This one cost me an hour because nothing looked wrong. I passed an array of ids into a query as an argument, and I built that array fresh on every render with .map(...). In JavaScript a new array is a new identity even if the contents are identical. Convex keys a subscription by its arguments, so a new argument identity looks like a brand new query: it re-subscribes and re-runs from scratch. Every render. For a query doing N lookups, that's N reads on every render, for nothing.

The fix is boring and important: keep query arguments stable. Memoize the array, or better, push the work to the server so you pass a single id instead of a list the client keeps rebuilding. Stable args in, no phantom re-subscriptions.

One more, for free: immutable history doesn't need a subscription

Old messages don't change. So there's no reason to keep them in a live query at all. The clean shape for a long feed is to keep only the newest page as a reactive subscription, and load older history with a one-shot fetch (useConvex().query(...)) that reads it once and never watches it again. Live where things change, static where they don't.

The takeaway

If a Convex bill looks insane next to how little data you actually store, don't go hunting for a giant document. Go hunting for the multiplier. Bandwidth is result size times re-reads, so the wins are all about shrinking one of those two numbers: paginate growing lists so only the newest page re-runs, never rewrite a growing value in a loop, bound or denormalize anything that would otherwise .collect() a whole table, keep query arguments stable, and don't subscribe to data that can't change. None of it is exotic. It's just the same question asked five ways: is this query re-reading more than it has to, more often than it has to?