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

작성자

카테고리:

← 피드로
DEV Community · Dheeraj Akula · 2026-08-17 개발(SW)
Cover image for Two users, tiny data: how my Convex chat app read tens of MB a day

Dheeraj Akula

I was staring at my Convex dashboard, confused. Convex is the reactive backend behind a chat app I’m building: it stores the data, and 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 messages added up to a few hundred kilobytes, tops. My first guess was that I’d accidentally stored something huge, so I went digging for a giant document. There wasn’t one. The data really was tiny, and the meter was still right.

The multiplier I wasn’t seeing

The meter itself measures exactly what you’d expect: bytes of documents read and written. No surprise there. What I hadn’t internalized is that it counts them per function execution, and in Convex, a reactive query re-executes every single time any document in its result changes. Reads don’t happen once. They happen every time anything moves.

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

So bandwidth isn’t the size of your data. It’s:

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

Enter fullscreen mode Exit fullscreen mode

The 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, I stopped asking “what did I store” and started asking “which queries re-read a lot of data, a lot of times.” That question found every problem below.

The message list re-shipped itself on every message

My message list was a plain useQuery that returned the conversation’s messages. Every new message made that query re-run and re-send 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 longer the conversation got, the more each new message cost.

The fix is Convex’s usePaginatedQuery, and the reason it works is the single most useful thing I learned from this whole mess: 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:

I’d always thought of pagination 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.

Streaming a reply wrote the same text over and over

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. Each write carries the whole string so far, so a reply of N tokens writes 1 + 2 + ... + N ≈ N²/2 tokens’ worth of bytes on its way to a document that ends up holding N. A reply that’s a few KB of final text can write tens of KB getting there. And the reactive read side makes it worse, because every one of those writes also re-ships the message to every subscriber. Drag the token count and watch the two approaches split apart:

The fix: stop rewriting the whole buffer. Append only the new piece on each flush, or stream the text over a separate channel and write the document once at the end. Same final message, linear cost. The lesson generalizes, too. Anything that rewrites a growing value inside a loop is a quadratic trap waiting to happen, and reactivity multiplies the damage.

.collect() reads the whole 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();

Enter fullscreen mode Exit fullscreen mode

.collect() pulls every matching row into an array. My daily-usage check ran on every message and collected the whole conversation each time, so the read scaled with table size, on the hot path.

What to do instead depends on what you actually need. If you only need recent rows, bound the query: .order("desc").take(25) reads at most 25 rows instead of all of them. If you only need a count or a sum, denormalize it: keep a running counter on a parent document, update it on write, and 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.

The query that kept re-subscribing

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(...). Convex keys a subscription by its arguments, so the fresh array identity made every render look like a brand new query: re-subscribe, re-run from scratch. For a query doing N lookups, that’s N reads per 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. Once the argument identity stops changing, the phantom re-subscriptions stop with it.

Old messages don’t need to be live

Old messages never 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.

If your bill looks wrong

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 every win shrinks 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 the same question asked five ways: is this query re-reading more than it has to, more often than it has to?

I’m Dheeraj, a software engineer at Nutanix Enterprise AI working on agent
harnesses and developer tools. I write up the problems that took me too long to
work out. More at dheerajakula.dev/blog.

원문에서 계속 ↗