Recently, I've been working mostly on figuring out how Convex can scale up to meet our customers needs. And so, this is part of the magic of Convex is that like we've [music] let you fall into the pit of success. Always by like making code push atomic. If your client wants to run [music] like 100,000 mutations right now, there's probably some that's probably a bug in your code. How does it code go from here to there? Then, does it does this process pass it across or does one run? Very naive. I'm sure there's lots of reasons why not. This is a I'm an external person going, "Oh, have you not done this?" And it's like, "Yes, we have thought about that, Michael. Thank you." Yeah. Well, like there's just many ways to tackle the problem. Yeah. Yeah. So, um, thanks for joining me today, Emma. We're going to talk about something a topic that I've been interested in for a while, which is like what actually happens when I push my code to convex? How does it actually get executed? What sort of steps does it go through? I've heard about this thing called FunRun before, but what exactly is it and how does it work? But before we get to all that, maybe you want to tell me a little bit about yourself. You're an engineer here at Convex, how long you've been here, what you do, etc. Yeah, I'm Emma. Um, I guess we've been working together for since you've been here, but I've been here for almost four years. And I mostly work on like the core backend system side of things. And recently, [snorts] we've been calling that infrastructure. But I think since the start of the company, we've all worked kind of across the stack on many things. Yeah. But recently, I've been working mostly on figuring out how Convex can scale up to meet our customers needs because things are scaling. Yeah. Yeah. We have a lot of customers with really high workloads and they're growing and their companies are becoming successful and we are super excited to be on that journey with them and also create a backend that can scale for anyone. What I wanted to do today is go a bit deeper on a specific part that I didn't really cover much. which I covered like the kind of the database side a little bit and there's probably more to do there but I would like to talk about what actually happens when I have my code in my IDE and then we how how does that actually get executed on convex like how does it go from my IDE up to some nebulous convex cloud and then get executed when the user makes a query from their browser can you can take me through that process maybe on the whiteboard or something yeah let's do it okay so you asked me um what happens how do I get my code from my editor into Convex and then how do functions run? So I guess we can draw this is your IDE and you're probably using this Convex CLI, right? Yeah. So you'll probably run a command like npx convex dev. Yep. Um or if you're pushing to prod, you might be running npx convex deploy. Deploy. Yeah. And so this um there's a few requests that are happen in this command, but I'll just talk about um a couple of them. So this is your deployment. Mhm. For those not super familiar with convex, a deployment is just like the backend that your convex project. It's the back end for your Convex project. So it's like an instance of convex. So this is something that I didn't realize until I joined Convex. I assumed that there was just a gigantic thing that is convex and you just slice off a chunk of that. But that's not the case, is it? It's almost like there's little Docker containers for each back end, each deployment. Is that right? Two years ago there was, but last year we create a multi-tenant service that serves thousands of deployments um in a single Docker container. Okay. So it is it is you it is multi-tenant now but as at the beginning it was like every deployment was its own um nomad job and like its own container its own process and the reason for doing that is it just lowers the resources required for each one. Yeah, it was like a ton of overhead because um lots of instances are idle and they're not like they're not being used very much and we would be have compute and memory allocated for them that they weren't using. And the age of vibe coding, you know, with convex chef and other platforms like that, you know, being able to spin up like tens of thousands, hundreds of thousands of these deployments and then have them not do anything for a long time is expensive. Yep. Yeah. Yeah. For sure. All right. So when you run npx convex dev um if you're you're running for the first time you get a new fresh deployment and you can think of this as the database like when you look in the dashboard this deployment is the one that has all of your tables um and you have code in your so this is where your code is right and we send your code up in mpx comics dev and there's like one request that's um start pushed and we send your code And actually so from here we get your code and then we actually have a separate service called funr run um that will analyze your code. So we use V8 which is Chrome's JavaScript runtime to analyze your code and make sure it type checks and like I mean we can't tell it works but like type checking is good enough to be like this code is probably good. We store your modules. So we talk to S3 um and we store your modules after we're sure that your code type checks. But it's in S3 is it interesting. So yeah. Okay. Okay. Cool. So how much of convex is actually on Amazon and because I've heard convex is on planet scale but that's just the database part but some of it is on Amazon as well. Yes. Okay. Yes. So we store files and modules and our text and vector indexes on S3. Text and vector indexes on S3. Yeah. Interesting. So okay, that's a whole that's a whole another time. Vector indexes. We'll go down that one another time, I think. Yeah. Um and I'll just maybe I'll draw like the database. This is the this is planet scale right now. Yep. As an external kind of service. Um but in the past it has been RDS and we've migrated between like Postgress and MySQL many times and you can read blog posts about that if you're curious. Yeah. Um but right now we're on my SQL planet scale and it's VEST I guess it's their version they're like super fast my SQL engine whatever. Yeah. Sorry my handwriting is not very good. And not to be confused with the test which is something completely different. Yeah. But um this database part is irrelevant for right now. We're just talking about code push. Yes. And so we okay we've we've like checked the code is good. Um there's another couple things that you can do in your push which is like you might have a schema. So right here we're sending up your code um your schema [snorts] and your schema has like indexes defined in it. And so in start push your deployment will start like building in there's like an index worker in here. Um and like there's also like a schema validation worker. Once all of that is done um like the CLI the CLI is pulling and like waiting is it ready? Is it ready? And then once it's ready, it'll send a finish push request. And then um that's the point at which instead of running functions from your old version of code, it'll run functions from your new version of code. And it waits to make sure that those indexes that you might have added that your new code relies on um are built and ready to use and all of your schema the SK new schema is validated. So like you can think of like pushing code. You have like an old version and you have a new version. Mhm. And we do this maybe complicated start push and then wait for everything to finish before like sending this finish push request to make sure that the cut over between your old version and your new version is atomic. So you will never be running um a version of code with incompatible indexes and incompatible schema. I've been there before on my own personal projects where I have on Postgress I have like I'm halfway through a push and I have pushed the database so the database has migrated but the code's not been uploaded and there's that little window of time where users could get into that weird state. Yeah, that can never happen on Convex. Yeah, this really saves you writing a lot of backwards compatibility code because once you know like you only have to write code for the schema and indexes that exist in your new version which is really nice cuz like a lot of in a lot of other systems you'd have to write code that's compatible with both of them and then it's like a mess and it's really easy to break things and so this is part of the magic of Convex is that like we've let you fall into the pit of success there. So always by like making it code push atomic. Yep. Yeah. So we call this like atomic cuz all of the changes applied at once. Ah is this atomic code push? Yeah. Ah okay. So this is another topic that we might talk about. So then the V8 runtime over here. Is there a reason why you guys chose V8 over a different execution engine like Spider Monkey or JSC the um one that Bun uses? Um, so at the time like this would be 2021, this V8 was like the most tried and tested and honestly it's not like there might be better alternatives right now but it's a lot of effort to go and change that and there's so many compatibility issues like we already have a lot of customers who want to write their functions using some node functionality and then we have to route them to use um ad the node actions that use AWS Lambda. Yeah. and we're constantly filling gaps in anything that people need in like the V8 environment. So, we've done a lot of work to like make sure the V8 environment works for us. So, yeah, but it's true. There's like up and coming JavaScript runtimes that maybe in the future we would switch to. Okay. Okay. Yeah, that makes sense. Yeah. Okay. So, we have our code pushed. It's now sitting in I think S3. It's now we've done our type checks. The indices I might just leave there for a second. We'll talk maybe talk about indices a little bit later. Yeah. Yeah, but I'm interested to know now like what happens when a user from a browser um makes a request? How does it actually then execute on fun run? What's the steps that it goes through? Yeah. Okay. Interesting. Um let's do you want to wipe it and start again or um we got space? I think we have space. Okay. So like like yeah, you're right. Like how how does convex actually used? Well, usually you have a client and that lives in someone's web browser usually, unless they're using like one of the other clients. Um, and the client for simple like there's actually a whole distributed system behind this, but to simplify it, let's just say the client is making opening a websocket with the deployment. So there's this websocket connection with your deployment. Um, and when you execute a query or a mutation or an action, I guess there's maybe it's important to just name the three kinds of functions. So, um, we have queries, uh, mutation and action. Yep. And then like inside action there's like the V8 actions and then there's the node actions and I guess there's also HTTP as well. Um but yeah just just for starters let's like talk about queries and mutations because that's the core of convex like this is what is happening transactionally within the database. So um when your deployment receives a request to run this query or run this mutation it actually um forwards the request to fund run with a timestamp. So if you're familiar with are you familiar with optimistic concurrency control? Um I am. I've did a video on how comets work. So if people want to know a bit more about that but yeah I mean if you'd like to give me a little brief overview of it. Yeah. So what's really interesting is so your deployment contains the committer um which is the the like the committer is the process that is uh actually like writing to the database. So in the database we have this we treat the database basically as an appendon log with your documents and your indexes and the committer um writes to the database. And what's interesting though is that funr run has to talk to the database to be able to execute your functions but it doesn't it it has readon access. It doesn't ever write to the database. Yes. So what happens when uh you execute a function is that you send a request like like um request to like a function request. This could be a query or mutation. Yeah, a query or mutation to fund run and it will have um the instance name. So instance being the deployment. Oh yeah, let's just call it deployment name. Okay. deployment does. Sorry, I'll let you finish. Go on. Oh, it'll have like the time stamp. Yeah. Um to begin running the function on and that that time stamp will basically load a snapshot of the database at that time stamp. Yeah. So that you have the data you need to run your query or mutation. Um and then you'll have the arguments um and like the function name, the function path, function reference. Yeah. Yeah. Yeah. Basically your identifier for like which function are you actually apitodos dot add to-do or something. Exactly. Exactly. Yeah. Um and the reason why we include the deployment name is because actually fun can run functions from any deployment. So it's a standalone service like a singleton service that is well it's going to run on a cluster of machines effectively. Is that um yeah there's there's a it's not really a singleton because there's a bunch of them running but it is a standalone service that serves requests from like any it can serve requests from any deployment any deployment within its little uh cluster. Yeah. Yeah. Okay. Um and this is what allows Convex to scale because V8 actually has a limit. you can only run 128 concurrent um like functions and a lot of our customers are running way more than that and so like back in the day when we I don't know 3 years ago this V8 execution environment was actually inside the deployment process and that was a hard limit like 128 concurrent functions but because we split it out into a separate service called funr run we can actually support like thousands of concurrent mutations and queries and actions um for a single deployment. I've got so many questions. Why why does V8 do you know why V8 has this limit? Why why do they have that thread limit of 128? What's is it couldn't tell you but probably it does take memory like it takes memory it takes CPU to do to spin up but then we have to spin up multiple V8 instances to be able to have more. So then how much memory does a VA instance use without do you know without any anything running without anything? I'm not sure but um is that a lot? Are we talking like gigs or No, but we we actually limit the amount of memory for your for each isolate to I think 500 megabytes. It might be a little bit lower than that. Okay. It's like definitely on the order it's like more maybe 100 to 500 megabytes. Okay. Um and and our fun run machines are running on like 16 cores and I think we give eightish of those to V8. Okay. Yeah. And so each fun run machine has just got one V8 runtime running. Is that right? Yeah. Like like you can only have one V8 in your process. Yeah. Okay. Okay. All right. Okay. So do they limitation of V8 itself? It like the Yeah. Yeah. It's okay. I wonder whether other runtimes do that like spider monkey or or JavaScript core or something like that. I wonder whether they they have those limitations. Yeah, I mean it makes sense to have limitations like we have really strict limitations as well just to protect our own like to make sure we're not each fun isn't biting off more than it can chew of course. Yeah. So you don't want to it's that multi-tenency thing. You don't want to have one person's deployment if they stick a while loop forever preventing other people from doing work. Yeah. We have all kinds of limits to make sure that um we reduce noisy neighbor problems problems. Yeah. Yeah. Yeah. So one question I was going to ask is then so I understand this. So you you've sent a request to this kind of it's singleton not singleton service front run running. How does the code go from here to there? Then does it does this process pass it across or does fun run? Um does it does it just download it every time from Yeah, there's a module cache. So, we have a bunch of caches, but one of them is I guess I'll just say we cache modules uh indexes. Yeah. Modules and indexes on the fun run um on the fun run instance itself. The service itself, the fun run service. Yeah. Okay. Okay. So, it doesn't have to redownload your code from S3 every single time. Yeah. Okay. Okay. And and we make sure that the deployments route to the same fund run instances to make sure that we get those cache hits. And only if like if this deployment is sending way more load do we go to um more fun runs. Okay. And part of this is the version number for your particular function or your deployment because obviously every time you do a push if you if you've got the same function name to-dos.addto-do add to do, but you could have version two, three, four, five, six. That's just a part of that. Otherwise, that cache would be stale and be pulling out the wrong code from S3. Is that right? Mhm. Um, I don't we don't send the version number here. Wait, do I'm not sure if we send it here or if we get it here. The cache is invalidated at that point. Yeah. Yeah. Yeah. At at the start push, maybe the cache gets in I don't Yeah, it's Yeah. Yeah. Yeah, cash invalidation is one of those [laughter] computer science perennial computer science problems. It's hard. Mhm. Yeah. Okay. Interesting. So, so [clears throat] our code is now on the fun run instance. We run it uh and I've done this in the in a past video. It uh is able to read from the database. Mh. But then it not able to write not even mutations are able to write back the database. That's correct. Yeah. Yeah. Yeah. So when after your f your code has executed on funrun um it'll like return a right set or writes from from your mutation um to the deployment and then your the committer will commit the rights to the database. It'll check for conflicts. So the committer checks for conflicts. So that right could be something like I want to insert to-do with this structure into the to-dos array or something this to-dos table. Mhm. And but it's not actually writing it. It's saying that I want to do this. Yeah. And the commit is then going to check to see whether that's okay because if anybody has made any changes before your time stamp when you said you wanted to start doing this then you might end up with a conflict. Yep. Yeah. So you'll the the commander will look at this timestamp and check if there were um any rights to anything that you read and then um like abort the commit the like can't just fail this function uh if there were rights yeah that would conflict and once it fails we actually internally will retry the function. So we'll just like send a new request to fund run with a new time stamp and hope it succeeds. And that's an OCC. Yeah. So if you ever see in your um code like there's an error that says like data written while this function was running. Yeah. That is talking about this conflicting process. And that is one of the perennial problems that the great thing about convex is that this this whole step process works 95% of the time works perfectly. But when it doesn't work, when you have a lot of load on certain types of scenarios, you get a lot of those errors. And that's where like components come in because they've been designed to try and minimize these kind of conflicts and things like that. on just encouraging good architecture like we've definitely uh run into um OCC errors a lot in building convex and usually the solution is to like separate out the field that's being written to very frequently and the other fields that are read that don't need to be invalidated every time something is written. Yeah. Yeah. Yeah. Yeah. Fantastic. Um, what I do want to ask is, um, so you did a lot of fun run stuff, uh, I think two years ago, I think I read saw a stat post. Yeah, maybe I'll show this on screen. Um, where you split Fun Run out into a new service or something or you did a bunch of work on it. Um, could you maybe talk through what happened there and why you did it and whether there's been any sort of major changes since then? Yeah. Yeah, I think so. It goes back to this like 128 concurrent function functions limit in V8. So before um fun run all of your functions had to run inside your deployment and so we would have this hard limit of 128 concurrent functions and our customers were outgrowing that and we want to be able to scale to any workload but we're not there yet but we definitely can run like thousands of concurrent functions now and that was the motivation for making fun run. Yeah. Yeah. And so has there been any other major changes since then? Maybe I think oh yeah there has. So originally we only put code execution in fund run. So that would be like running functions, queries, mutations and actions. Um what I talked about at the beginning where we run npx convex dev and we actually analyze your code in funrun that is a relatively new addition. We only did that a year ago because we were working on making deployments served by a multi-tenant service. Now I'll introduce a new concept um which is that we have a bunch of deployments running inside a service called conductor. I've heard about this conductor. Yeah, I want to know what what is this? Yeah. So we made this chain basically um while before we had conductor we had a V8 environment in here still um that was analyzing your code when you do npx convex dev and we were only sending your functions to run in fun run but because v8 takes a lot of memory once we had a multi-tenant service serving like originally it was like a hundred deployments at a time um we wanted to pack as many deployments into conductor as possible. It was actually kind of crazy like we because they every every deployment was its own um separate process running on its own like machine. I guess technically there was machine sharing but like it still took our it took like four or five six hours for us to push code like push our own updates to the deployments. Um, so we once we had conductor and we had like uh now we have thousands of of deployments on each conductor. It's like way faster us for us to push out code updates and like a huge motivation to pack more. So that was like a huge motivation to pack more um deployments on a conductor also for like our cost savings as well. But the limiting factor was V8 because it takes up so much memory. So we got rid of V8 out of this um deployment and then we put all of the V8 requests. So like analyzing code and schema whatever in fun run. Mhm. So that that was a big change and that made memory usage go down a bunch for each deployment. Yeah. Yeah. So now now we're basically at running like 3,000 deployments on each conductor. Wow. Yeah. Nice. How big are those machines? How big are the conductor machines? Oh, that's a good question that I don't know the answer off the top of my head. Yeah, I'm gonna talk to somebody else about that mate. Emperor stuff maybe. Yeah. Would you know? I've got another question while while you're here. I was just wondering what would be the um things that you could do to improve like say you said you could run thousands of fun. What would be the limits then? Why would there be limits? Why couldn't you just run infinite number of concurrent um queries or mutations? Um well because we only have like a certain number of fun runs. So we want if you if you if your client wants to run like 100,000 mutations right now Yeah. there's probably some that's probably a bug in your code. Correct. Because like Yeah. Like we it's probably an unreasonable workload unless we're used to that happening. Yes. Um, and instead of like just letting all those mutations go out to FunRun and then having fun run crash for everyone, we limit you here with your like mutation limit. We have like a um a semaphore that lets I guess the first I don't know. I think the limit is like 256. I could be wrong, but I'm pretty sure the limit is 256. It lets the first 256 mutations go through and then like you wait like the rest kind of wait for um some of those to come back. Yeah. And then you can run it's like a DOS protection. [laughter] Yes, pretty much. Yeah. So, but there's no actual physical reason why not. It could fund Run could scale infinitely. Oh, yeah. It could. It could if we just had many more machines. I guess there is some sort of resource when a request comes in from the client here. there's some sort of resource that's going to be held in memory while it waits for this fun run to come back. So effectively there is probably a limit there that that resource will you couldn't have infinite unbounded amounts of memory usage in that deployment there. Yeah. Yeah. Your arguments and stuff. Yeah. You you would probably run out of memory at some point. Yeah. That would be interesting. Yeah. Yeah. I guess I think you had some question about like what could be improved about fun run. Yeah. Well, yeah, maybe. Yeah, we fixed on that. What's what's for the future of fun run? What what do you think improvements can make? Yeah. Like one thing that's top of mind, we sort of talked about the utilization of conductor and how we were able to like part of how we can run convex really efficiently and cheaply and like have all like a really generous free plan and like starter plan um is because we're able to pack many conductors on or sorry many deployments onto a conductor. Um one thing that's we're not doing very well right now is like utilizing fun run. um to its full capacity. Basically, we have like this problem where uh there's a few deployments that have a lot more load than other deployments and they mostly send their requests to the same fund runs for those cache hits. But that means that some fund runs run really hot like close to their limit of like using, you know, maybe they're using 12 out of the 16 cores at steady state. And then other fun runs are pretty idle and so they're maybe using one or two cores out of the Yeah. And so we want to do better load balancing between deployments and fund runs. Is it possible to have a shared cache? I assume that caches in memory. Could you we use um um what's it called? Um mem cache or something like that. A separate service for caching as opposed to in memory caching. Yeah, we have thought a little bit about that. But I think like we also it's not just the cache that it well we also just want the the CPU like we mainly just want the graph of the CPU instead of like like having a bunch of lines at the top and a bunch of lines at the bottom to be like more everything is in the middle. Yeah. Yeah. Yeah. And I guess if you had a global cache and you could send the request to anywhere. Yeah. Robin or something. Yeah. Yeah. You would have like a much better distribution. Yeah. So maybe So maybe maybe that's an idea. Yeah. An idea. Yeah. Yeah. Yeah. There's like very naive. I'm sure there's lots of reasons why not. This is a I'm an external person going, "Oh, have you not done this?" And it's like, "Yes, we have thought about that, Michael. Thank you." Yeah. Well, like there's just many ways to tackle the problem. I understand. I understand. But it is an open problem. Like we haven't if we already knew the answer, we would have already done it. Yes. So, so like load balancing is just one of these things that's on the horizon for us as um yeah, as we want to make fun run more or better utilized. Yeah. Yeah. And I should just mo note that we are a very small team with very limited resources. Can't do everything at once. Constantly trying to battle growth at the same time as improving the system. So I think it's definitely one of those open call moments like if this kind of thing interests you then [laughter] apply for comx you know. Hell yeah. comics.dev/job I think it is. Yeah. Yeah. We're definitely hiring like I guess to be really transparent I think we have 11 t people on the engineering team. 11. Yeah. And and yeah, I think this project writing fun run was like me and one and a half other people on the project and conductor also was like a three-person project. Mhm. Like all this stuff, it was a very small group of people working on it, but it works amazingly well. So you guys must have done a a fantastic job. Oh, thanks [laughter] Mike. We try. Yeah. Um fantastic. All right. Well, thanks Emma. Thanks for that. Um, it's a very informative. I now understand a little bit more about how my code goes from the IDE to the deployments to fun run and back again. So, I appreciate it. Thank you. Awesome. Thanks, Mike. No worries. Cool.
Convex users often ask what the difference is between typing code in your editor and that code running when a user hits your app. There are really two journeys hiding in that question. The first is the trip your code takes from npx convex dev up to a live deployment. The second is the trip a single request takes from someone's browser, through our execution fleet, into the database, and back. Here's both of them, including the parts we haven't figured out yet.
From npx convex dev to a deployment
When you run npx convex dev for the first time, you get a fresh deployment. That deployment is the backend for your specific Convex project: your tables, your functions, your indexes. When you open the dashboard and look at your data, you're looking at that deployment. Every one of them is its own logical backend, which trips people up. A lot of developers picture "Convex" as one giant shared system that gets sliced into per-customer slivers, and that's not the model at all.
How a deployment is hosted has changed a lot, though. Two years ago every deployment ran as its own Nomad job with its own container and its own process. That was easy to reason about and expensive to run. Most deployments sit idle most of the time, so we were holding compute and memory for workloads that weren't happening. Multiply that by tens or hundreds of thousands of deployments, which is what you get in a world where people spin up backends from a prompt, and the waste adds up fast on any cloud platform.
Last year we moved to a multi-tenant service called Conductor that runs thousands of deployments inside a single container. More on why that mattered later.
Start push: type-checking your code in V8
Once you have a deployment, npx convex dev or npx convex deploy sends your code up in what we call a startPush request. Your deployment doesn't analyze that code itself. It hands it to a separate service called FunRun, which runs it through V8, the same JavaScript engine Chrome uses.
What FunRun is doing there is type-checking. We can't tell whether your code works, and we're not trying to. Type-checking is a strong enough signal that the code is probably fine, and it catches an enormous class of mistakes before anything touches real data. Once it type-checks, we store your modules in S3.
Where your code actually lives
The S3 part surprises people, because the thing everyone associates with Convex is the database, and that's not S3. Our database currently runs on PlanetScale, on Vitess, their sharded MySQL engine. It hasn't always. We've been on RDS, and we've moved between Postgres and MySQL more than once over the years.
None of that matters for a code push, though. Your modules, your files, and our text and vector indexes all live in S3, separate from the transactional data. So a push is mostly a conversation between the CLI, your deployment, FunRun, and an object store. The database only enters the picture when your schema does.
Schema and index workers, then an atomic cutover
If your push includes a schema, that same startPush kicks off two more workers in parallel. An index worker starts building any new indexes your schema defines. A schema validation worker checks the new schema against the data you already have, because a schema you can't actually satisfy with existing documents is a problem you want to hear about now.
Meanwhile the CLI sits there polling. Is it ready? Is it ready? Only when both workers have finished does it send a finishPush request. That's the moment your deployment stops running the old version of your code and starts running the new one.
A Convex push
That two-step shape is deliberate. We wait until the new indexes are fully built and the new schema is fully validated before cutting over, so there's never a window where your deployment is running new code against an old, incompatible schema. Anyone who has done manual deploys elsewhere has probably lived through the alternative. The database migration lands, the code doesn't ship for another minute, and for that minute your users are hitting a genuinely broken state.
That window doesn't exist here. Every change in a push applies at once, which is why we call it an atomic push, and it buys you something you might not notice until you go back to another system. You never have to write code that works against both the old schema and the new one. In most other setups that defensive, works-either-way code is unavoidable, it piles up over the years, and it's very easy to get subtly wrong.
We picked V8 back in 2021, when it was the most tried-and-tested JavaScript engine going, ahead of SpiderMonkey or the JavaScriptCore engine Bun uses. Honestly, there might be better options today. But swapping the execution engine under a production system is a mountain of work and a pile of compatibility risk, so it isn't something we'd do casually.
The engine choice does leak through to you in one place. Plenty of people want Node-specific functionality that our default runtime doesn't support, and those get routed to Node actions running on AWS Lambda instead. We keep closing gaps in what the default V8 environment can do, and there are newer runtimes we may eventually move to, but that's a bigger conversation for another day.
The three kinds of functions
Before tracing a request, it's worth naming what can actually be at the other end of one, because the runtime story is different for each. Convex gives you three kinds of functions. One of them splits in two, and there's a fourth entry point worth knowing about.
Function kind
Where it runs
What it's for
Query
FunRun, default V8 runtime
Reads your data at a fixed timestamp. Transactional and cacheable.
Mutation
FunRun, default V8 runtime
Reads and writes transactionally. Returns a writeset rather than writing directly.
Action (default runtime)
FunRun, default V8 runtime
Talks to the outside world, where transactions don't apply.
Action (Node)
AWS Lambda
Same job, for code that needs Node APIs the default runtime lacks.
HTTP action
FunRun, default V8 runtime
An action reached by a raw HTTP request instead of a client call.
Queries and mutations are the core of Convex, and they're the interesting ones here, because they're what runs transactionally against the database. Everything below them in that table exists because the outside world refuses to participate in your transaction.
What happens when a client makes a request
Once your code is pushed and type-checked, here's what happens when someone's browser actually calls it. A client, usually in a web browser, opens a WebSocket connection to your deployment. There's a whole distributed system sitting behind that connection, but for this story you can treat it as one socket to one deployment.
Convex client request
When the client executes a query or a mutation, your deployment doesn't run it. It forwards the request to FunRun, and the request looks roughly like this:
Two of those fields are doing more work than they look like they are.
The timestamp decides which snapshot of the database FunRun loads. Your function then sees a consistent view of your data as of that exact moment, however long it takes to run and whatever anyone else is doing in the meantime.
The deployment name matters for a different reason. FunRun can run functions from any deployment, so it needs to be told which one this request belongs to. That one field is why FunRun can be a shared fleet at all, and it's what took the ceiling off how much load a single Convex app can handle.
The committer and optimistic concurrency control
Your deployment holds a component called the committer, and the committer is the only thing in the entire system that writes to the database. We treat the database roughly as an append-only log of documents and indexes, with the committer as its sole writer. FunRun gets read access and nothing more.
That's true even for mutations, which surprises people. The mutation code running on FunRun never writes anything. It reads, it works out what should change, and it hands that answer back.
What it hands back is a writeset: the list of changes the mutation wants to make. The committer takes that writeset and decides whether to apply it, and this is the point where optimistic concurrency control enters the picture. The committer looks at the timestamp your function ran against and asks one question. Has anything you read changed since then?
If the answer is no, the write commits. If the answer is yes, the commit fails, and we retry it for you by sending the function back to FunRun with a fresh timestamp. Most of the time you never find out any of this happened.
When OCC conflicts show up in your app
Sometimes you do find out. If you've ever seen an error along the lines of "data written while this function was running", you've met the conflict path described above. What it means is that two functions read and wrote overlapping data close enough together that one of them lost the race, and the retries didn't clear it. Your code is fine. The system is reporting contention, which is a load problem rather than a logic one.
This gets more likely in exactly one situation: a single document that's being written to very frequently while other functions are reading it. Every one of those writes invalidates every read of that document, so the more traffic you put through it, the more collisions you get.
The fix lives in your schema. Pull the field that changes constantly out into its own document, away from the fields that are read often and rarely change. A view counter living on the same document as a post's title and body means every view invalidates every read of the post. Move the counter somewhere of its own and the collisions mostly evaporate.
We've run into this plenty while building Convex itself, and the answer has been the same every time. It's also why Convex components are designed the way they are, with hot fields deliberately kept apart from cold ones. The architecture nudges you toward the layout that doesn't fight itself.
Splitting execution out into FunRun
FunRun exists because of one number. V8 can run roughly 128 concurrent functions in a single process, and that's a hard ceiling.
Three years ago, execution happened inside your deployment process, so that ceiling was your ceiling. Customers were outgrowing it. Splitting execution out into a standalone service is what fixed it. FunRun runs across a cluster of machines and isn't tied to any one deployment, so an app under heavy load can be served by many FunRun instances at once. That's thousands of concurrent queries, mutations, and actions instead of 128.
I want to be careful about how far to push that claim. The goal is a backend that scales to any workload you throw at it, and we aren't there yet. Thousands of concurrent functions is a real answer to a real problem, and it isn't the last one we'll need.
The 128 figure itself comes down to memory and CPU. Every V8 isolate costs real resources to spin up, and we cap isolate memory somewhere in the 100 to 500 megabyte range (closer to the top of that range in practice). A FunRun machine runs on 16 cores with roughly eight of them given over to V8, and a process can only host one V8 runtime, so concurrency is bounded by how many isolates fit inside that budget.
Some of that ceiling is there on purpose. We keep strict limits on what any one deployment can consume, because FunRun is shared. One app stuck in an infinite while loop shouldn't be able to starve everyone else on the machine, and noisy neighbors are a lot easier to prevent than to apologize for.
Module caching and routing
Your code doesn't get re-downloaded from S3 on every request. FunRun instances keep a local cache of modules and indexes, and we lean on that hard.
Routing is built around it. We send a given deployment's traffic to the same FunRun instances whenever we can, purely to keep that cache warm, and we only spread a deployment across more instances once its load genuinely demands it. Cache hits are the whole reason that routing rule exists.
Which raises the obvious question about pushes. When you deploy a new version of a function, what stops a FunRun instance from happily serving the old cached copy? There's a version attached to your code that keeps the two straight. Exactly where that version gets resolved, sent along with the request or looked up on the FunRun side, is the kind of detail I'd want to go read the code before answering confidently. Cache invalidation earned its reputation as one of computing's perennially hard problems, and this is a small reminder of why.
Conductor and multi-tenancy
FunRun started out doing only execution, meaning the queries, mutations, and actions themselves. Moving code analysis into it happened about a year ago, and that move was tied directly to the shift toward multi-tenant hosting.
Here's the connection. Before Conductor, every deployment ran V8 inside its own process to do that analysis, and V8 is memory-hungry. That memory was the single biggest thing standing between us and packing more deployments onto a machine. Life in that world was rough in a second way too: with every deployment as its own process, rolling out one of our own internal updates across the fleet took four, five, sometimes six hours.
Conductor fixed the density problem by hosting many deployments inside one multi-tenant container, and ripping V8 out of the deployment process is what let us pack them in properly. Early Conductor instances ran around 100 deployments each. Today we run about 3,000 per instance, our fleet-wide pushes finish in a fraction of the time they used to, and the cost per idle deployment dropped enough to fund the free plan.
Protecting the system: the mutation semaphore
Nothing about FunRun stops it from scaling forever, given enough machines. The real ceiling shows up well before that, and we put it there ourselves.
Say a client fires off 100,000 mutations at once. Nobody meant to run that. It's a bug in somebody's code. Passing all of them straight through to FunRun would put the whole service at risk for every other deployment sharing it, so we don't. A mutation semaphore caps each deployment at somewhere around 256 concurrent mutations. The first 256 go through and the rest wait their turn as the earlier ones come back.
Call it what it is, which is DDoS protection pointed inward. There's a second reason for it too. Every queued request holds memory while it waits, its arguments and its state, so an unbounded queue would eventually run the deployment out of memory even if FunRun never blinked.
What's still unsolved
The thing that's top of mind for me right now is FunRun utilization, and it comes straight out of that cache-affinity routing rule from earlier.
A handful of deployments carry far more load than everyone else, and cache hits keep sending them back to the same FunRun instances. So those instances run hot, sitting at maybe 12 of their 16 cores at steady state. Meanwhile plenty of other FunRun instances are using one or two cores out of 16. We're paying for all of them.
What I want is boring: a CPU graph where the lines sit in the middle, rather than a cluster pinned near the top and another cluster scraping along the bottom. Getting there means better load balancing between deployments and FunRun instances. A shared or global cache, something along the lines of memcached rather than per-instance memory, would give routing much more freedom, and we've thought about it. Cache locality is only part of the problem though, and it's the CPU distribution we actually want to fix.
This one is genuinely open. If we already knew the answer, we'd have shipped it.
We're a small team
All of this, FunRun and Conductor both, came out of a very small group of people. FunRun was me and about one and a half other engineers. Conductor was a three-person project. Convex's entire engineering team is around 11 people.
That's part of why we talk about this stuff publicly. We're fighting growth and improving the system at the same time, with a lot more scaling work ahead of us than hands to do it. If the open problems in here sound like your idea of a good time, we're hiring at convex.dev/jobs
All gas, no breakages
Convex is the reactive backend platform that keeps up with you and your agents. Database, functions, workflow, sync, search, file storage, and more. All TypeScript, zero glue.