Engineering

How We Use Convex for Real-Time Sync Without the Complexity

A deep dive into why we chose Convex as our backend and how its reactive query model eliminates an entire class of state management bugs.

By Lumo EngineeringFeb 17, 20263 min read

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.

Loading...