There’s a moment every developer knows: you’re building a feature, things are going well, and then you need to add real-time updates. Or you need to invalidate a cache. Or you need to make sure two operations happen atomically. Suddenly, your “simple” feature becomes a week of infrastructure work.
We’ve been there more times than we’d like to admit. REST APIs, GraphQL subscriptions, Redis caching layers, database transactions, optimistic updates that don’t quite work right. It’s exhausting.
Then we tried Convex. And honestly? We’re not going back.
What Even Is Convex?
Convex is a backend-as-a-service, but that label undersells it. It’s more like “what if your backend just worked the way you always wished it did?”
How Convex Works
Here’s the core idea: you write TypeScript functions that run on the server. These functions can query and mutate your database. When data changes, every client that’s subscribed to that data updates automatically. No WebSocket setup. No pub/sub configuration. No cache invalidation logic.
It sounds too good to be true. We thought so too.
The “Aha” Moment
Let me show you something that made us converts. Here’s what a typical data-fetching setup looks like with a REST API:
// api/users.ts - your API route
export async function GET(req: Request) {
const users = await db.query.users.findMany();
return Response.json(users);
}
// hooks/useUsers.ts - your custom hook
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
staleTime: 5000,
});
}
// When you create a user, you need to invalidate
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
Now here’s the same thing in Convex:
// convex/users.ts
export const list = query({
handler: async (ctx) => {
return await ctx.db.query("users").collect();
},
});
export const create = mutation({
args: { name: v.string(), email: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("users", args);
},
});
// In your component - that's it
const users = useQuery(api.users.list);
const createUser = useMutation(api.users.create);
When you call createUser, every component using useQuery(api.users.list) updates automatically. No invalidation. No stale data. No extra code.
Real-Time Without the Headache
We recently built a collaborative feature where multiple users edit the same document. With a traditional stack, this would mean:
- Setting up WebSocket connections
- Building a message protocol
- Handling reconnection logic
- Managing optimistic updates
- Dealing with conflict resolution
- Writing a lot of tests
With Convex, we wrote this:
// convex/documents.ts
export const get = query({
args: { id: v.id("documents") },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});
export const update = mutation({
args: { id: v.id("documents"), content: v.string() },
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { content: args.content });
},
});
That’s it. Every user viewing the document sees changes in real-time. The subscription is automatic. Reconnection is handled. We moved on to building actual features.
Developer Experience
TypeScript All the Way Down
One thing that’s hard to appreciate until you experience it: Convex is TypeScript end-to-end. Your database schema, your functions, your client calls—it’s all typed.
// convex/schema.ts
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
role: v.union(v.literal("admin"), v.literal("member")),
createdAt: v.number(),
}).index("by_email", ["email"]),
});
Now when you write a query or call it from the frontend, you get full autocomplete and type checking. Misspell a field name? TypeScript catches it. Pass the wrong type? TypeScript catches it. Change your schema? TypeScript tells you everywhere that needs updating.
This isn’t just nice-to-have. It’s a different way of working. You catch bugs at compile time instead of in production.
How It Stacks Up
We’ve used Firebase and Supabase on other projects. They’re good tools. But here’s how Convex compares:
Backend Comparison
| Feature | Convex | Firebase | Supabase |
|---|---|---|---|
| Real-time by default | Requires setup | ||
| TypeScript end-to-end | |||
| ACID transactions | |||
| Automatic caching | |||
| Built-in scheduling | Cloud Functions | pg_cron | |
| File storage | |||
| Server functions | TypeScript | JS/TS/Python | SQL/Edge |
| Auth integration | Clerk/Auth0/etc | Built-in | Built-in |
| Local development | Hot reload | Emulators | Docker |
| Pricing model | Usage-based | Usage-based | Usage-based |
The standout differences for us:
TypeScript end-to-end — Firebase and Supabase give you JavaScript/TypeScript clients, but the backend logic often involves different languages or paradigms. Convex is TypeScript from your schema definition to your React components.
Automatic caching and invalidation — This is the big one. With Convex, you never think about caching. It just works. With other tools, you’re constantly managing query keys, invalidation, and stale data.
ACID transactions — If you need two operations to succeed or fail together, Convex handles it. No distributed transaction complexity.
The Things That Surprised Us
A few things we didn’t expect:
Local development is seamless. Run npx convex dev and you have a fully functional backend with hot reload. Change a function, it’s live immediately. No Docker containers, no emulators, no configuration.
The dashboard is actually useful. You can browse your data, see function logs, debug issues, all from a clean interface. It’s not an afterthought.
Scheduled functions just work. Need to send a reminder email in 24 hours? One line:
await ctx.scheduler.runAfter(24 * 60 * 60 * 1000, api.emails.sendReminder, {
userId: args.userId,
});
File storage is built in. Upload files, get URLs, no S3 configuration. It’s not a separate service to manage.
When Convex Might Not Be Right
We try to be honest about tools we recommend. Convex isn’t perfect for every situation:
-
If you need raw SQL — Convex uses a document database. If your app is deeply relational with complex joins, you might want Postgres.
-
If you’re locked into AWS/GCP — Convex is its own infrastructure. If you need everything on your cloud provider for compliance, that’s a consideration.
-
If you need fine-grained access patterns — Convex’s permissions model is good but might not cover very complex multi-tenant scenarios out of the box.
-
If you’re doing heavy data analytics — Convex is optimized for application data, not analytical workloads.
For most web and mobile apps, though? It’s hard to beat.
Our Stack Now
For new projects, our default stack has become:
- Frontend: Next.js or React + Vite
- Backend: Convex
- Auth: Clerk (integrates beautifully with Convex)
- Hosting: Vercel
- Styling: Tailwind + shadcn/ui
This combination lets us move incredibly fast. We spend time building features, not infrastructure.
Getting Started
If you want to try Convex, it takes about five minutes:
# Create a new project
npm create convex@latest
# Or add to existing React app
npm install convex
npx convex init
The documentation is excellent. Start with the “Get Started” guide, then build something small. You’ll feel the difference immediately.
Final Thoughts
We’ve become the annoying friends who won’t stop talking about Convex. But there’s a reason: it genuinely changed how we think about building apps.
The backend shouldn’t be the hard part. It shouldn’t be where you spend your innovation tokens. You should be able to focus on what makes your product unique, not on caching strategies and real-time synchronization.
Convex lets us do that. We hope it can do the same for you.
Have questions about Convex or want to see how we’ve used it in our projects? Reach out—we’re happy to share more.