Why we needed a reactive backend
When we started building Lumo, we knew real-time sync was non-negotiable. A personal finance app that shows stale data isn't just annoying — it's dangerous. Users need to see their balances, transactions, and budgets update the moment something changes.
We evaluated several approaches: polling, WebSockets with a custom sync layer, Firebase Realtime Database, and Convex. Each came with trade-offs we had to weigh carefully.
The problem with traditional approaches
Polling
The simplest approach — hit an API every N seconds — falls apart quickly for finance apps. You either poll too frequently (wasting bandwidth and battery) or too infrequently (showing stale data). There's no middle ground.
WebSockets + custom sync
Building a custom sync layer gives you full control, but the complexity is staggering. You need to handle:
- Connection lifecycle (connect, disconnect, reconnect)
- Message ordering and deduplication
- Conflict resolution
- Optimistic updates and rollbacks
- Subscription management
We estimated 3-4 months of engineering time just for the sync layer before we could ship a single feature.
Firebase Realtime Database
Firebase is battle-tested, but its data model (a giant JSON tree) doesn't map well to relational financial data. Joins are manual, transactions are limited, and the query capabilities are basic.
Why Convex won
Convex gave us three things we couldn't find elsewhere:
1. Reactive queries as a primitive
In Convex, every query is automatically reactive. When underlying data changes, all clients subscribed to affected queries receive updates instantly. No WebSocket management, no subscription tracking, no manual cache invalidation.
// This query automatically updates when any transaction changes
export const getRecentTransactions = query({
args: { userId: v.id("users"), limit: v.number() },
handler: async (ctx, args) => {
return await ctx.db
.query("transactions")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.order("desc")
.take(args.limit);
},
});On the client, useQuery subscribes to this and re-renders whenever the result changes. Zero boilerplate.
2. ACID transactions
Financial data demands consistency. Convex mutations are full ACID transactions — if any part fails, everything rolls back. This is critical for operations like transferring money between accounts.
export const transferFunds = mutation({
args: {
fromAccountId: v.id("accounts"),
toAccountId: v.id("accounts"),
amount: v.number(),
},
handler: async (ctx, args) => {
const from = await ctx.db.get(args.fromAccountId);
const to = await ctx.db.get(args.toAccountId);
if (!from || !to) throw new Error("Account not found");
if (from.balance < args.amount) throw new Error("Insufficient funds");
await ctx.db.patch(args.fromAccountId, {
balance: from.balance - args.amount,
});
await ctx.db.patch(args.toAccountId, {
balance: to.balance + args.amount,
});
},
});3. TypeScript end-to-end
The schema definition generates TypeScript types that flow through queries, mutations, and into the client. A schema change surfaces type errors across the entire stack immediately.
Architecture overview
Our Convex backend lives in packages/backend/ as a shared package. Both the mobile app and web app import from the same generated API:
packages/backend/convex/
schema.ts # Single source of truth for all tables
transactions.ts # Transaction queries and mutations
accounts.ts # Account management
budgets.ts # Budget tracking
_generated/ # Auto-generated types and API
Both apps import identically:
import { api } from "@lumo/backend/convex/_generated/api";This means a schema change or new function is immediately available to both platforms with full type safety.
Lessons learned
Start with the schema. Convex's schema-first approach forced us to think carefully about our data model upfront. This saved us from painful migrations later.
Embrace the reactive model. We initially tried to use Convex like a traditional REST API (fetch once, manage state manually). Once we leaned into reactive queries, we deleted hundreds of lines of state management code.
Index early. Convex queries without indexes scan the entire table. We learned to define indexes in the schema for every query pattern we use in production.
What's next
We're exploring Convex's new vector search capabilities for smart transaction categorization and building a real-time collaboration feature for shared household budgets. More on that in a future post.