2026-10-16
Cloud is great, but Local First!
Why I decided to run the Convex backend self-hosted on my Mac: the story behind bun run dev:local, from connectivity issues to an incident with an IoT module that took down cloud services. With visibility hooks, DevTools bridge, and MCP server.
The cloud developer experience is great, especially if you're using Convex as your backend. Type-safe end-to-end, hot reload of queries and mutations, dashboard with live logs, the npx convex dev flow that syncs your code against a brand-new dev deployment in seconds. For a startup or a distributed team, it's one of the best DX you'll find.
The problem is that when you live in a city or a region where the internet fails constantly and so does the power, having local development is indispensable. And this is one of the reasons I've had so many problems lately. On top of that, the services I had running on the Convex cloud server stopped completely when one of my IoT device implementations exploded — meaning it saturated the server with requests and stopped all Convex services in other projects.

The Convex cloud DX is excellent, until it stops being
npx convex dev --start vite and in 30 seconds you have a freshly provisioned dev deployment, the code deployed, and the frontend talking to the cloud. The CLI warns you when a query is slow, shows you the logs, lets you inspect tables from the dashboard. For validating features, doing demos, iterating on UI, it's hard to ask for more.
But "30 seconds" assumes two things:
- That you have internet.
- That the shared cluster you've been assigned isn't saturated.
Both fail in the reality of a developer who lives far from the data centers.
The internet goes down, and the whole flow goes down with it
My first full day without internet at home made me realize something obvious I'd been ignoring: without internet, there's no convex dev, no deploy, no logs, no dashboard, nothing. The code you have locally is only good for looking at, because the queries return undefined and the mutations reject with Failed to fetch. The browser shows the eternal spinner.
I thought that a good phone tether would be enough. It's enough to read emails and reply to chats, not to keep a WebSocket session open against Convex for hours. Every time the network drops, the Convex client disconnects, the queries stop updating, and the Vite dev server starts throwing errors that pollute the console.
And the power is worse. When the lights go out, they come back after 20 minutes, go out again, and in the end you've got the router in some weird state that only fixes itself by unplugging it for two minutes. In that context, depending on an external SaaS to iterate on code is, at best, annoying. At worst, it eats a whole day of your work.
The incident that made me take the problem seriously

But the straw that broke the camel's back wasn't the connectivity — it was a phone call. A client called me because the app wasn't responding. The app was on the same tier where I was running the tests, so when the requests hit the tier limit, Convex Cloud triggered the safeguard that disables every project in that tier to avoid more billing. Up to that point, it's appreciated: Convex isn't to blame for the mess I caused. The problem is what came next: I had to log in and raise the spending limit, after receiving 3 invoices in about 20 minutes. That one hurt to pay.
What had happened: I had an IoT devices module that sent coordinates to Convex every time the device moved. In the sandbox simulator, the coordinates changed on every tick, and each change fired a setLocation which in turn updated a live map in another component. The combo "many devices + reactive queries on the dashboard" saturated the shared cluster. What I didn't expect: Convex services from OTHER projects living in the same region also went down. A single implementation of mine with a miscalibrated loop took down availability for clients I didn't even know.
That's when I understood that "deploying to the cloud to see if it works" isn't only about function call cost. It's about blast radius: a single bad query can take down services that aren't yours. Convex charges you per function, but the real cost of an unvalidated deploy is the collateral damage.
The hidden cost of a real-time database

Convex (and any reactive backend) wins on immediacy: useQuery updates itself when the underlying document changes, no polling, no manual cache invalidation, no client having to know which table to watch. That's the magic.
The problem is that very same immediacy is what makes you lose control. In a traditional REST system, a query that's called too often makes itself felt: the server slows down, the logs blow up, the dashboard complains. In a reactive system, the symptom isn't slowness: the symptom is silent cluster saturation. Every useQuery open is a WebSocket subscription. If your dashboard has 12 widgets, that's 12 connections the backend has to maintain and reconcile every time something changes. If your widgets are in a scrollable grid, the widgets that scroll out of the viewport stay there, querying, until React unmounts them.
Multiply that by N open tabs, N concurrent users, and an IoT device changing coordinates 10 times per second. The user's "eternal spinner" becomes "all Convex services in this region are down" for everyone else.
That's the price: you gain immediacy, you lose control over the load if you don't measure it. And the only way to measure it seriously is locally, where you can break whatever you want without affecting anyone.
How we solved it: visibility hooks for widgets
The first step was admitting that a widget out of the viewport doesn't need to be alive. If nobody sees it, it shouldn't query anyone. Sounds obvious, but it's not the default behavior of a reactive useQuery: the SDK keeps the subscription open while the component is mounted, regardless of whether it's visible or not.
We implemented three hooks in hooks/live/visibility.tsx that combine two browser signals:
// Page Visibility API + IntersectionObserver for the widget on screen.
// Live-data hooks (useLiveQuery, useVisibleQuery) don't query anything
// while it's not visible.
usePageVisible()usesdocument.addEventListener("visibilitychange", ...)and returns true only if the tab is visible.useElementInView(element)wraps IntersectionObserver with a rootMargin: 200px to start loading a bit before it scrolls into view.useInView()anduseIsSeen()are the combination:tabVisible && widgetOnScreen.
The InViewBoundary wraps each dashboard widget and publishes the visibility state into a Context. Any useQuery inside reads it and decides whether to schedule the next fetch. The consequence is direct: when the user switches tabs, all reactive subscriptions on the dashboard pause and the runs counters in the DevTools bridge stay quiet.
useLiveQuery also doesn't query "every time something changes" willy-nilly. It uses a pulse + throttling system: 10-second throttle, 5-minute safety net, and if the data didn't change it doesn't fire a new fetch. That turns "many noisy subscriptions" into "queries only when there's something real to show".
I measured it with the DevTools bridge I describe below. Tab visible with 9 widgets in viewport: the live registry reports 9 entries with subs=N, seen=True, loaded=True. Switch the tab to hidden with simulateHidden and those same 9 widgets report seen=False. No reload, no touching anything. The subscriptions are still there (we don't unmount the React tree), but the source of the next fetch goes silent. That's the difference between "having 9 open connections" and "having 9 idle connections that don't read".
For this to scale, the important thing is that we don't only apply it to one widget: every useVisibleQuery and every useLiveQuery in the system inherits that gate, so there's no "forgotten widget" that ends up being the leak.
The DevTools bridge + MCP server: what was missing to iterate without blowing up the cluster
Once the frontend is full of subscriptions, you need a way to look inside the cluster from your editor, without opening the Convex dashboard. And you need an AI agent to be able to do it too, because part of the workflow is asking the agent to "check whether the quotations query is causing problems".
We implemented three paths to the same snapshot:


- In the browser: window.__heskala exposes an object with the same shape. The "Live" tab on the debug panel reads it and draws how many widgets are alive, how many are cached, when the last fetch happened.
- Vite plugin (devtools/vite-plugin.ts): writes .devtools/snapshot.json every time it changes and exposes GET /__devtools/snapshot, /__devtools/deployments, /__devtools/switch, POST /__devtools/command. The deployment switch lives here: it rewrites .env.local and emits a full-reload to the client. The "command" endpoint accepts a closed list of commands: snapshot, report, top {n}, errors, live, offline, reset, simulateHidden {hidden}, simulateOffline {offline}, navigate {path}. None of them write data, none of them execute user code.
- MCP server (devtools/mcp-server.mjs): a dependency-free Node binary that speaks JSON-RPC over stdio and exposes the same commands as typed tools: get_snapshot, get_report, convex_top, measure {seconds}, get_errors, get_live, get_offline, reset_counters, simulate_hidden, simulate_offline, navigate. Configurable in any MCP client (Claude, Codex, Cursor, ChatGPT) and without touching the frontend code.
The snapshot the bridge returns is a JSON with the contract described in devtools/schema.json. A real example of the dashboard open locally with an active session:
{
"tabId": "9d0iuarq",
"session": {
"version": "v0.9.76",
"convexUrl": "http://localhost:3210",
"roleType": "company_admin"
},
"page": { "route": "/dashboard" },
"convex": {
"functions": [
{ "name": "system/dashboard/queries:getDashboardStats", "adds": 100, "runs": 100, "active": 0 },
{ "name": "system/dashboard/queries:getDashboardStatsPulse", "adds": 24, "runs": 12, "active": 6 },
{ "name": "system/permissions/queries:getUserPermissions", "adds": 6, "runs": 3, "active": 1 },
{ "name": "companies/quotations/queries:getQuotationsOptimized","adds": 3, "runs": 3, "active": 1 }
]
},
"live": [
{ "key": "system/dashboard/queries:getDashboardStats:{...quotations...}", "subscribers": 3, "seen": true, "loaded": true },
{ "key": "system/dashboard/queries:getDashboardStats:{...clients...}", "subscribers": 1, "seen": true, "loaded": true },
{ "key": "system/dashboard/queries:getDashboardStats:{...products...}", "subscribers": 2, "seen": true, "loaded": true }
]
}
And the banner you see in the dashboard screenshot is the active-deployment indicator: it shows whether the client is talking to http://localhost:3210 (Local) or https://whatever-convex-id.convex.cloud (Cloud dev), with a colored dot that changes based on the backend state. An agent can call simulate_hidden to force the hidden-tab state and see the effect on the seen flags of the live registry.

The most useful thing in this triad is measure {seconds}: it resets the counters, waits N seconds, and returns the delta. You run it before touching a query, touch the query, run it after, and compare. If runs/min went up 300% and active did too, you know you broke something before doing a git push.
The answer: self-hosted backend locally

I decided that the development flow had to run 100% on my Mac. No internet, no cloud, no surprises. Convex publishes official self-hosted builds (Ubuntu + precompiled binary), so technically the path was open. The hard part was the developer experience: npx convex dev against the cloud is magical because it provisions a dev deployment, syncs the schema, redeploys on every change, and leaves logs in real time. I wanted the same flow, but pointing at http://localhost:3210 instead of https://whatever-convex-id.convex.cloud.
What I ended up building is bun run dev:local. A single command that:
- Starts OrbStack (or Docker Desktop) if the daemon is asleep.
- Brings up the self-hosted Convex backend in a container with 4 GB of RAM and 4 vCPUs.
- Starts a "deployer" in watch mode — the exact equivalent of
npx convex devbut pointing at localhost, which detects changes inconvex/,lib/,packages/and redeploys in ~5 seconds. - Rewrites
.env.localso the frontend (vite) talks tohttp://localhost:3210. - Starts Vite on
http://localhost:5173.
The first deploy takes 30 seconds (cold start of the container, install deps, generate the RSA pair for Convex Auth). The next ones are instant: edit a file, save, and the change is on the local backend before the browser reloads.
The Convex "no" that still stings
Before closing: I want to make one thing clear for those evaluating this path. Convex doesn't have a native self-hosted option ready for bun run dev. It's not plug-and-play. What exists:
- Precompiled binaries of
convex-local-backendthat you have to run on your own infra. - Official image
ghcr.io/get-convex/convex-backendthat's private (you need a token). - A CLI that knows how to talk to the cloud, but against self-hosted you have to configure it manually with
--env-fileandCONVEX_SELF_HOSTED_URL.
What I had to build myself, as a developer who wanted an equivalent of npx convex dev pointing at localhost:
- A local Docker image with Node 22 + the binary baked-in.
- An entrypoint shell that waits for the backend, sets the env vars in the deployment, and stays in watch mode.
- Selective bind mounts so the Mac code shows up in the container.
- A bash script that orchestrates: checks the Docker daemon, starts it if needed, brings up the stack, does a healthcheck, runs the deployment switch, and kills old Vite processes on the port.
- An MCP server so the agent can look inside the cluster.
Each of those pieces we built and committed because there was no other option. It's work the Convex team could one day do all at once (a convex dev --self-hosted with an embedded daemon, a UI that lists local deployments), but today it doesn't exist. In the meantime, everything you see in the repo under docker/onprem-dev/, scripts/dev-local.sh, devtools/ and convex/lib/pdf/storage/ is the glue we wrote to make the flow work.
If you want to build it from scratch, it'll take you a day. If you come with the repo, it's bun install && bun run dev:local.
Why this flow doesn't ruin your day when the internet comes back
What I value most about running local: I can be as aggressive as I want with experimentation. Want to try a useQuery that opens 50 subscriptions? Go for it. Want a backfill script that writes 200k documents? Go for it. Want to see what happens to the search_flusher when you import a 127k-doc zip? Go for it.
In the cloud, each of those tests spends function calls, can degrade the dashboard, and — as I learned — can take down the service for third parties. Locally, the only victim is my Mac. If a query is bad, the worst case is that it hangs until I restart the container. The data is mine, the cluster is mine, the responsibility is mine.
And if at some point I want to go back to the cloud to validate against real data or show someone progress, I switch from the dashboard banner and Vite rewrites .env.local to point at whatever-convex-id.convex.cloud. The Mac keeps running with the local backend, the browser talks to the cloud, and in 3 seconds I'm in production without having touched a YAML.
How my work changed
The rule I adopted after the incident is simple: nothing reaches cloud dev unless it first passes through local and measures well. It's a cost: I have to keep the devbox running, remember to regenerate the admin key if I reinstall OrbStack, validate that the deployer image stays compatible with the latest binary version. But it's a cost I pay once per setup session, not every time the network drops or an experiment gets out of hand.
Concretely, what I do before promoting any change to cloud dev:
- Run the migration / script / widget against the local backend.
- Measure with the DevTools bridge before and after:
reset_counters, do the action,convex_top {n: 20}to see the delta. - If the metrics look healthy (subscriptions didn't bloat,
runs/mindidn't multiply), I switch to the cloud and measure again. - If it looks worse in the cloud, I go back to local and optimize before retrying.
Step 2 is what the MCP measure {seconds} does in a single call. The good thing about the bridge living in the repo is that any AI agent can use it without me having to paste screenshots.
Who this makes sense for
If you live somewhere with stable internet, npx convex dev probably already covers you. But if:
- You code in regions with flaky network infrastructure.
- You work on IoT / simulator / device-integration projects that generate a lot of load.
- You share a cluster with other projects and care about
blast radius. - You want to experiment with heavy mutations without paying function calls.
- You want an AI agent to be able to audit consumption without you pasting logs.
...then running Convex self-hosted locally is worth the day it takes you to set it up. It's not plug-and-play yet, but with the script and image already built, the actual setup is five minutes. After that, bun run dev:local becomes as natural as npx convex dev.
And the best part: the next time the lights go out or an IoT experiment goes off the rails, you'll laugh. 😜
Newsletter
Recibe los próximos posts
Sin spam. Solo cuando publique algo nuevo.
← volver al blog