A Year on Convex: What I'd Tell Past Me About Building Two Products On It
We shipped Wryte and OurTrail on Convex over the last twelve months. Here's where it earned its keep, the small things that bit us, and the architectural calls I'd make differently if I were starting again.
A year ago, when we started Wryte, we made a call that turned out to shape every other technical decision that followed: we put the whole backend on Convex. When we started OurTrail three months later, we made the same call on purpose.
I want to write the post I wish I'd had when we started. Not the marketing comparison ("Convex vs. Firebase vs. Supabase!") and not the rage-quit ("ten things wrong with serverless backends"). Just: what actually happened, what saved us, and the architectural decisions I'd revisit if I were starting today.
Why we picked it (the honest reason)
The marketing line is "reactive database, backend functions, real-time subscriptions, cron jobs, and dev tooling in one platform." That's all true. The reason it actually landed for us was smaller: I didn't want to build another set of REST endpoints just to fetch a list of things.
Both apps are read-heavy and live. Wryte's dashboard updates as articles move through Draft → Review → Published, often on a second monitor while you write. OurTrail's trip page updates when a cousin uploads a photo from her phone. Building that with the traditional stack — Postgres, an ORM, an Express route, a TanStack Query hook, plus a separate WebSocket layer for the realtime part — felt like four jobs for a four-person team.
Convex lets you write the query once, in TypeScript, and the client just gets updates. That's it. That's the pitch. The rest of the platform is what you discover after you say yes.
The things it actually earned its keep on
Schemas you can refactor. This sounds banal. It isn't. Convex's schema lives in convex/schema.ts as TypeScript validators, and renaming a field is a refactor your editor does for you. There is no "migration" step in the Rails sense for additive changes — you change the validator and the function signatures it touches, push, and you're done. For destructive changes you write a one-off mutation. For Wryte, where the frontmatter schema for articles changed five times before launch, that's the difference between "let's just ship it" and "we'll get to that next sprint."
Realtime subscriptions that aren't a special path. In OurTrail, when a member uploads a photo, every other member's trip page updates within a couple hundred milliseconds. We did not write a WebSocket handler. We did not write a cache-invalidation message. The query that lists trip media is the same query that powers the live view. The client subscribes; Convex tracks which rows the query touched; when those rows change, the query reruns server-side and the result is pushed. The plumbing is the platform.
Durable workflows for scheduled work. Wryte has scheduled publishing — set a date and time, and the article gets pushed to GitHub when that moment arrives. The first thing I tried was a Convex action that called setTimeout for the delay. The second thing I tried was Convex's cron support. The third thing — and the one that's actually correct — was @convex-dev/workflow, which gives you durable, retry-able, suspended-across-deploys workflows that the Convex runtime is responsible for resuming.
// convex/publish.ts
import { workflow } from "./_generated/server";
export const scheduledPublish = workflow.define({
args: { articleId: v.id("articles"), publishAt: v.number() },
handler: async (step, { articleId, publishAt }) => {
await step.sleepUntil(publishAt);
await step.runMutation(internal.articles.markPublishing, { articleId });
await step.runAction(internal.github.pushToRepo, { articleId });
await step.runMutation(internal.articles.markPublished, { articleId });
},
});If the Convex deployment restarts halfway through, the workflow picks up where it left off. If pushToRepo throws, it retries three times with backoff. None of that is code I wrote. All of it is code I would have had to write, badly, on a traditional stack.
The things that bit us
Heavy media doesn't belong inside Convex. This was the first lesson, and the only painful one. OurTrail's photos and videos can be hundreds of megabytes. Routing those through a Convex action — even one that just forwards to Cloudinary — is an anti-pattern: actions have time and size limits, and the whole point of Cloudinary is direct upload. The right shape, which we learned in the second week:
- The client asks a Convex action for signed upload params.
- The client uploads directly to Cloudinary.
- The client calls a Convex mutation to record the resulting URL.
Convex never touches the bytes. It's a coordinator and a metadata store. Once we internalized that — "Convex is for state, not for bytes" — a lot of confused early code got deleted.
Pagination is a pattern, not a feature. Convex queries return arrays. If you have ten million rows, you can't just .collect() them in a query. You have to paginate, which Convex supports cleanly via paginate({ cursor, numItems }), but you have to want to use it. The footgun is the day-one query that's db.query("media").collect() and works fine until the first trip has 800 photos and the dashboard freezes. We hit this exactly once, in OurTrail, on a debug page. Now I write .paginate() first and prove I don't need it second.
Real-time is a default with a cost. Convex subscriptions are cheap, but they aren't free. Every subscribed query is one your backend is going to rerun whenever its inputs change. We had a "recently active trips" widget on OurTrail's home page that was subscribed; it depended on tripMembers, which mutates every time anyone joins anything; the widget was rerunning constantly for users who weren't even looking at it. The fix was a one-line change — use Convex's usePaginatedQuery with a stale-friendly hook instead of a fresh subscription — but the lesson was bigger. Default to a one-shot fetch. Opt into live updates only where they earn the cost.
You can't run heavy compute inside a query or mutation. This is in the docs and we still tried it twice. Image transcoding, ML inference, scraping external sites — those want to live somewhere else (a queue, an external service, an action with a workpool). Convex tells you politely; production tells you rudely. By the second time, I was reaching for @convex-dev/workpool reflexively whenever a task looked like it might block.
The decision I'd revisit: user-supplied secrets
This is the one I want to flag because it's the kind of architectural thing you only think about after you have customers.
The first version of Wryte's AI features used ANTHROPIC_API_KEY from environment variables. That works exactly until somebody asks "can I bring my own key?" — which is a reasonable ask, and which a lot of users will make if you ever charge for AI features. Refactoring out of the env-var pattern is more painful than it sounds, because the assumption is woven through every action that touches a model.
We ended up moving the keys into WorkOS Vault, with one record per user per provider, decrypted per-request inside the Convex action that calls the model. The architecture is cleaner now — the platform doesn't know anyone's keys — but if I were starting again, I would not put the first version's AI keys in environment variables. I'd start with per-user secrets from day one. The cost on day one is one extra mutation; the cost of migrating later is a week.
The "exit cost" question
A real one: if Convex ever went away or got expensive, what's our plan?
Honest answer: it's harder to leave Convex than it is to leave Postgres, because we're not writing SQL and our data access lives in Convex functions, not in a portable ORM model. The validators are TypeScript, but the shape of how we query is Convex-y — paginated cursors, indexed queries, server-side joins via document IDs.
Two things mitigate this for us. First, we keep the domain logic outside the Convex functions where we can — the Convex function is a thin handler over a service module that doesn't know about Convex. So if we ever ported to Postgres, we'd be replacing the data layer, not the business layer. Second, Convex's open-source self-hostable backend (released earlier this year) is a real escape hatch, even if a stressful one.
The lock-in is real. I think it's worth it for what we got back in velocity. But it is a thing you should price into the decision when you make it.
Would I do it again?
Yes. Both products. Same backend.
The thing I'd repeat is the small-team math. We have five engineers. The traditional split — one person mostly on backend, one on infra, the rest on product — would have cost us a product. Convex collapses two of those roles into the same code, and the slack we got back went straight into shipping. Wryte's scheduled-publishing system is the kind of feature a small team usually pushes to v2. We shipped it in v1 because the platform made the cost line up with the value.
The things I'd do differently are smaller and more honest: paginate everything, treat real-time as opt-in, don't route bytes through the backend, and assume the first version of any auth-adjacent secret will need to be per-user before the year is out.
If you're a small team considering it: read the docs. Build a throwaway. Then build the thing.
Wryte and OurTrail are both built and run by the Syntax Lab Technology team. The architectural details above are from our actual codebases, not from documentation.
Ask AI