https://stack.convex.dev/how-convex-works Stack logo PatternsPerspectivesWalkthroughsAI Bright ideas and techniques for building with Convex. Profile image Sujay Jayakar 11 days ago How Convex Works How Convex works Introduction Over the past years, Convex has grown into a flourishing backend platform. We designed Convex to let builders just build and not have to worry about irrelevant details about administering backend infrastructure. Yet, curious developers have been asking us: How does Convex actually work? With our recent open source release, now is a perfect time to answer this question. Let's jump in. In this article, we'll go on a tour of Convex, starting with an overview of the system. Then, we'll focus on the system's core state "at rest," exploring what a Convex deployment looks like when it's idle. We'll then gradually introduce motion, seeing how live requests flow through the system. By the time we're done, we'll know the major pieces of Convex's infrastructure, how they fit together, and the design principles underlying its construction. Overview Let's get started with Convex by deploying James's swaghaus app from his talk "The future of databases is not just a database." This small demo app has a store where users can add items to their shopping cart, and items have a limited inventory. Users can't add out-of-stock items to their shopping cart. [image] Deploying Let's start by git clone'ing the repository and deploying to Convex and our hosting platform^1. [f20ad6be4bbaeb7c469466c2106e3f605f67d8e9-1588x931] Our codebase has two halves: the Web app starting from index.html that we build with vite build and deploy to our hosting provider and the backend endpoints within convex/ that we push with convex deploy to the Convex cloud. At its heart, a Convex deployment is a database that runs in the Convex cloud. But, it's a new type of database that directly runs your application code in the convex/ folder as transactions, coupled with an end-to-end type system and consistency guarantees via its sync protocol. Put another way, the most important thing to understand about Convex is that it's a database running in the cloud that runs client-defined API functions as transactions directly within the database. Serving Now that we've deployed our app, let's serve some traffic! Here's the high-level architecture diagram. [2ae339f39a62935266bd1518844dbdb32f5667f3-1252x832] Visiting our app at https://swaghaus.netlify.app creates a WebSocket connection to our Convex deployment for executing functions on the server and receiving their responses. Let's open up that opaque Convex deployment box and see what's inside. [080ed291f78449353715fd412f161633ac237078-1412x1627] There are three main pieces of a Convex deployment: the sync worker, which manages WebSocket sessions from clients, the function runner, which runs the functions in our convex/ folder, and the database, which stores our app's state. Convex at rest We'll start with the Convex deployment quietly at rest, right after deploying our app but before we've served any traffic. Let's take a closer look at our convex/ folder. There are two primary pieces: functions and schema. Functions All public traffic to a Convex app's backend must flow through public functions registered with query, mutation, and action from convex/ server. In Swaghaus, we have a query function getItems that lists all items in the store that still have some stock remaining: // convex/getItems.ts import { query } from "./_generated/server"; export default query({ args: {}, handler: async ({ db }) => { const items = await db .query("items") .withIndex("remaining", (q) => q.gt("remaining", 0)) .collect(); return items; }, }); Then, whenever the user adds an item to their cart, they call the addCart mutation. We've left out a few parts of this function for brevity, but you can always see the full source on GitHub. // convex/addCart.ts import { v } from "convex/values"; import { mutation } from "./_generated/server"; // Moves item to the given shopping cart and decrements quantity // in stock. export default mutation({ args: { itemId: v.id("items") }, handler: async ({ db }, args) => { // Check the item exists and has sufficient stock. const item = await db.get(args.itemId); if (item.remaining <= 0) { throw new Error(`Insufficient stock of ${item.name}`); } // Increment the item's count in cart. const cartItem = await db .query("carts") .withIndex("user_item", (q) => q.eq("userToken", userToken).eq("itemId", args.itemId) ) .first(); // Note: We're leaving out the code to insert the document // if it isn't there already. await db.patch(cartItem._id, { count: cartItem.count + 1 }); // Deduct stock for item. await db.patch(args.itemId, { remaining: item.remaining - 1 }); }, }); Our function runner uses V8^2 for executing JavaScript, and since V8 can't run TypeScript directly, we bundle (or compile) the code in your convex/ directory before sending it to the server for execution^ 3. This process also creates smaller code units that execute faster as well as source maps that help us provide high quality error backtraces. Tables and schema Apps can optionally specify their tables and validators for the data within them within a schema.ts file. Here's the one for Swaghaus: // convex/schema.ts import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ items: defineTable({ name: v.string(), description: v.string(), price: v.float64(), remaining: v.float64(), image: v.string(), }).index("remaining", ["remaining"]), carts: defineTable({ userToken: v.string(), itemId: v.id("items"), count: v.float64(), }).index("user_item", ["userToken", "itemId"]), }); This schema defines two tables, items and carts, along with expected fields and field types for each one. This schema also defines indexes on items and carts: More on indexes later. Tables contain documents, which can be arbitrary Convex objects. Convex supports a slight extension of JSON that adds 64-bit signed integers and binary data^4. All documents have a unique document ID that's generated by the system and stored on the _id field^5. So, our items table might contain a document that looks like... { _id: "j970pq0asyav77fekdj08grwan6npmh1", description: "Keeps you shady", image: "hat.png", name: "Convex Hat", price: 19.5, remaining: 11, } * Aside: Day 1 Ease... Convex doesn't require developers to declare a schema upfront, since it's really annoying to get started building an app and need to have everything figured out! We designed Convex to be a schemaless document database because of the incredible success of MongoDB and Firebase. MongoDB was fantastically popular when it first came out in 2009 and for good reason! Despite having glaring implementation issues, it gave developers the experience they wanted on the Day 1 of writing their app. Inserting and querying JSON objects directly into the database is refreshing after hours of fiddling with schema definitions, pondering how to normalize a data model, or tinkering with an ORM. * ...but also Year 2 Power. However, in our own experience and from talking to other startup founders, we found that they often had to migrate their systems off a Day 1 database once they found some success. Whether it was systems issues like performance or data loss, missing features like migrations, or the difficulty of understanding a structureless database in a large project, these teams often had to "graduate" to a more "serious" database in a painful app rewrite. Why should developers have to choose between Day 1 ease and Year 2 power? In programming languages, TypeScript has brought incremental typing to the masses, maintaining good ergonomics for early stage prototypes while allowing smooth adoption of rigor. Each step of rigor brings practical benefits, like better autocomplete, documentation, and catching common bugs. Putting all of this together, that's why we designed Convex's database to be amenable to loose schemaless models, schema-driven relational models, and everything in between. Taking a page from game designers, we want our system to have a smooth, gradually increasing difficulty curve without any abrupt jumps. The transaction log So far we've just discussed user facing parts of our system. Let's dive into our first implementation detail to see how Convex actually stores data internally. The Convex database stores its tables in the transaction log, an append-only data structure that stores all versions of documents within the database. Every version of a document contains a monotonically increasing timestamp within the log that's like a version number^6. The timestamp is purely an internal detail of the log, and it isn't included in the document's object^7. So, our database state in Swaghaus might have a transaction log that looks like... [46849de92a3a9faa7e393056c428a9a29b993a3a-1124x660] The transaction log contains all tables' documents mixed together in timestamp order, where all tables share the same sequence of timestamps. Each timestamp t defines a snapshot of the database that includes all revisions up to t. In our example above, we have two snapshots of the database, each corresponding to a timestamp in the log. Let's say we've added a Convex Hat to our cart, decrementing its remaining count in items and incrementing its count in carts. Applying these updates appends two new entries to the end of the log. [34f7865d20bbedf57a192279a41cef39eeed1ec0-1124x980] With these two new entries, the snapshot of the database at time 15 has two hats in our cart and 10 hats remaining in inventory. Since these two entries have the same timestamp, their changes are applied atomically: the database state jumps from the snapshot at 14 to the snapshot at 15 without revealing the intermediate state where we've decremented from items but not incremented in carts. To summarize, each modification of the database, whether it's inserting, updating, or deleting a document, pushes an entry onto the transaction log. Pushing multiple entries at the same timestamp allows us to batch up multiple changes to the database into a single atomic unit. The log contains deltas to the database state, and applying all of the deltas up to a timestamp creates the snapshot of the database at that timestamp. Indexes The transaction log is a minimalist data structure: It only supports appending some entries at the end and querying the entries at a given timestamp. However, this isn't powerful enough to access our data efficiently from queries. For example, if we're reading a snapshot of the database at timestamp 15, we don't know which of the entries correspond to the latest state of each document. Without further data structures, we'd have to scan the whole log from scratch to build up this snapshot. To make querying a snapshot efficient, we build indexes on top of the log. So, to look up documents by their _id field, we can maintain an index that maps each _id to its latest value. [731ffc87aab77be756fe915d6fc3fe6f5ecbf45b-1124x980] Whenever we push a new entry onto the transaction log, we update the index to point to our latest revisions. The log is the immutable source of truth; the index is derived data that we modify over time to help us efficiently find documents' latest versions. As drawn above, our index only supports finding the latest revisions at a single version of the database at timestamp 15. It turns out that it's useful to also support queries at multiple versions for timestamps in the recent past, making our index multiversioned. [1e78ad935e2b51227b93448cc752f5991152118b-1124x980] In this example, the _id index supports queries at the latest timestamp 15 but also past timestamps 14 and 13^8. One useful mental model of this approach is that the index is a data structure that allows efficiently mapping a point in logical time to a consistent snapshot of the state of the world at that timestamp. Starting it up: The reactor We now have all the pieces to start letting some requests through our system! Let's focus on the getItems query and addCart mutations we looked at earlier. Concurrency and race conditions For our Convex deployment to be Web Scale(tm), the backend needs to execute many getItems and addCart requests at the same time. Simply executing one function at a time is too slow for any real application ^9. Introducing concurrency, however, comes with its own problems. In many systems, the possibility of concurrent requests forces developers to handle race conditions where requests interact with each other in unexpected ways. Let's return to Swaghaus for an example of a race condition. Two users, Alice and Bob, are fighting over the last Convex Hat in our store and both call addCart at the same time. Alice starts executing addCart and observes that there's just one hat left. Great! // convex/addCart.ts import { v } from "convex/values"; import { mutation } from "./_generated/server"; // Moves item to the given shopping cart and decrements quantity in stock. export default mutation({ args: { itemId: v.id("items") }, handler: async ({ db }, args) => { // Check the item exists and has sufficient stock. const item = await db.get(args.itemId); if (item.remaining <= 0) { throw new Error(`Insufficient stock of ${item.name}`); } // <=== Alice observes `item.remaining === 1`. ... }, }); Then, let's say Bob's request sneaks in and grabs the last item. Since Alice's call to addCart hasn't finished yet, Bob also observes that there's one hat left, and he takes it. // convex/addCart.ts import { v } from "convex/values"; import { mutation } from "./_generated/server"; // Moves item to the given shopping cart and decrements quantity in stock. export default mutation({ args: { itemId: v.id("items") }, handler: async ({ db }, args) => { // Check the item exists and has sufficient stock. const item = await db.get(args.itemId); if (item.remaining <= 0) { throw new Error(`Insufficient stock of ${item.name}`); } // <=== Alice observed `item.remaining === 1`. // Increment the item's count in cart. const cartItem = await db .query("carts") .withIndex("user_item", (q) => q.eq("userToken", userToken).eq("itemId", args.itemId) ) .first(); await db.patch(cartItem._id, { count: cartItem.count + 1 }); // Deduct stock for item. await db.patch(args.itemId, { remaining: item.remaining - 1 }); // <=== Bob observed `item.remaining === 1`. // and sets `item.remaining` to `0`. }, }); We return successfully to Bob's web app, and he's overjoyed to have the hat in his cart. But, Alice's run of addCart continues, still believing that there's a hat left. // convex/addCart.ts import { v } from "convex/values"; import { mutation } from "./_generated/server"; // Moves item to the given shopping cart and decrements quantity in stock. export default mutation({ args: { itemId: v.id("items") }, handler: async ({ db }, args) => { // Check the item exists and has sufficient stock. const item = await db.get(args.itemId); if (item.remaining <= 0) { throw new Error(`Insufficient stock of ${item.name}`); } // <=== Alice observed `item.remaining === 1`. // Increment the item's count in cart. const cartItem = await db .query("carts") .withIndex("user_item", (q) => q.eq("userToken", userToken).eq("itemId", args.itemId) ) .first(); await db.patch(cartItem._id, { count: cartItem.count + 1 }); // Deduct stock for item. await db.patch(args.itemId, { remaining: item.remaining - 1 }); // <=== Bob observed `item.remaining === 1`. // and set `item.remaining` to `0`. // <=== Alice observed `item.remaining === 1`. // and also set `item.remaining` to `0`. }, }); In many databases, both Alice and Bob would get the hat in their cart, and item.remaining would be 0 in the database. Both of their orders would go through, and chaos would reign in Swaghaus's warehouse as we tried to fill two orders with the last Convex Hat. This anomaly is called a race condition since Alice and Bob's requests are "racing" to complete their requests and tripping over each other's writes. Race conditions often cause bugs in programs since it's hard to reason about all the possible ways our code can interleave with itself. In our example, our mental model of addCart didn't include the possibility of it pausing after performing the inventory check and letting someone else steal the hat we were looking at. Transactions We're stuck between two competing goals: We want to run many functions concurrently, but we don't want to have to reason about how concurrent function calls interact with each other. In an ideal world, we'd be able to reason about our functions as if they executed one at a time but still have the runtime performance of concurrent execution. Transactions are the missing piece that let us have our cake and eat it too. A transaction is an atomic group of reads and writes to the database that encapsulates some application logic. Transactions extend the idea of an atomic batch of writes to our transaction log to include both reads and writes. In Convex all queries and mutations interact with the database exclusively through transactions: all of the reads and writes in a function's execution are grouped together into an atomic transaction. Convex ensures that all transactions in the system are serializable, which means that their behavior is exactly the same as if they executed one at a time. Therefore, developers don't have to worry about race conditions when writing apps on Convex. In our previous example, our inventory count bug fundamentally relied on two calls to addCart being interleaved at time, and this bug is impossible in a serializable system. So, in Convex, Alice's transaction behaves as if it first executes to completion: // convex/addCart.ts import { v } from "convex/values"; import { mutation } from "./_generated/server"; // Moves item to the given shopping cart and decrements quantity in stock. export default mutation({ args: { itemId: v.id("items") }, handler: async ({ db }, args) => { // Check the item exists and has sufficient stock. const item = await db.get(args.itemId); if (item.remaining <= 0) { throw new Error(`Insufficient stock of ${item.name}`); } // <=== Alice observes `item.remaining === 1`. // Increment the item's count in cart. const cartItem = await db .query("carts") .withIndex("user_item", (q) => q.eq("userToken", userToken).eq("itemId", args.itemId) ) .first(); await db.patch(cartItem._id, { count: cartItem.count + 1 }); // Deduct stock for item. await db.patch(args.itemId, { remaining: item.remaining - 1 }); // <=== Alice sets `item.remaining` to `0`. }, }); Then, Bob's request executes to completion, failing with an insufficient stock error. // convex/addCart.ts import { v } from "convex/values"; import { mutation } from "./_generated/server"; // Moves item to the given shopping cart and decrements quantity in stock. export default mutation({ args: { itemId: v.id("items") }, handler: async ({ db }, args) => { // Check the item exists and has sufficient stock. const item = await db.get(args.itemId); if (item.remaining <= 0) { // <=== Bob observes `item.remaining === 0`. throw new Error(`Insufficient stock of ${item.name}`); } ... }, }); Bob is disappointed, but our application remains correct and doesn't make promises it can't keep. And, importantly, we have a great developer experience where we can fearlessly write concurrent code on Convex. * Aside: Why serializability? We believe that any isolation level less than serializable is just too hard a programming model for developers. It's well-known that writing correct multithreaded code is impossibly difficult, and reasoning about concurrency doesn't get any easier at a database's scale. Non-serializable transactions are an extraordinarily complex abstraction that provide too little to the developer. However, most database deployments are less than serializable! Postgres and MySQL, in particular, default to READ COMMITTED, a much lower isolation level that exposes many concurrency-based anomalies to developers. In our experience, many developers think they're getting more than they actually are from their database, and their applications have subtle latent bugs that only show up at scale. Let's now dive into how Convex implements serializability and gets the best of both worlds: Transactions can run in parallel and commit independently when they don't conflict with each other, and the system can process many requests per second. We provide the abstraction of one transaction happening at a time while also providing the throughput of a concurrent database. Read and write sets Convex implements serializability using optimistic concurrency control. Optimistic concurrency control algorithms don't grab locks on rows in the database. Instead, they assume that conflicts between transactions are rare, record what each transaction reads and writes, and check for conflicts at the end of a transaction's execution^10. Transactions have three main ingredients: a begin timestamp, their read set, and their write set. Let's return to Alice's call to addCart to see how this works. // convex/addCart.ts import { v } from "convex/values"; import { mutation } from "./_generated/server"; // Moves item to the given shopping cart and decrements quantity in stock. export default mutation({ args: { itemId: v.id("items") }, handler: async ({ db }, args) => { // <=== Alice starts a transaction at timestamp 16. // Check the item exists and has sufficient stock. const item = await db.get(args.itemId); ... }, }); The first step to executing a transaction is picking its begin timestamp (16 in our example). This timestamp chooses a snapshot of the database for all reads during the transaction's execution. It never changes during execution, even if there are concurrent writes to the database. Let's continue execution until we hit db.get(itemId), which looks up itemId in the ID index. After querying the index, we record the index range we scanned in the transaction's read set. The read set precisely records all of the data that a transaction queried. // convex/addCart.ts import { v } from "convex/values"; import { mutation } from "./_generated/server"; // Moves item to the given shopping cart and decrements quantity in stock. export default mutation({ args: { itemId: v.id("items") }, handler: async ({ db }, args) => { // Check the item exists and has sufficient stock. const item = await db.get(args.itemId); // <=== Alice queries `itemId` from `items`'s ID index at timestamp 16. // Alice inserts `get(items, itemId)` into the read set. if (item.remaining <= 0) { throw new Error(`Insufficient stock of ${item.name}`); } // Increment the item's count in cart. const cartItem = await db .query("carts") .withIndex("user_item", (q) => q.eq("userToken", userToken).eq("itemId", args.itemId) ) .first(); ... }, }); As we proceed through addCart, we query carts as well and record the index range q.eq("userToken", userToken).eq("itemId", itemId) in the read set too. Let's continue to the db.patch call, where we first update the database. // convex/addCart.ts import { v } from "convex/values"; import { mutation } from "./_generated/server"; // Moves item to the given shopping cart and decrements quantity in stock. export default mutation({ args: { itemId: v.id("items") }, handler: async ({ db }, args) => { // Check the item exists and has sufficient stock. const item = await db.get(args.itemId); if (item.remaining <= 0) { throw new Error(`Insufficient stock of ${item.name}`); } // Increment the item's count in cart. const cartItem = await db .query("carts") .withIndex("user_item", (q) => q.eq("userToken", userToken).eq("itemId", args.itemId) ) .first(); await db.patch(cartItem._id, { count: cartItem.count + 1 }); // <=== Alice increments `cartItem.count`. // Deduct stock for item. await db.patch(args.itemId, { remaining: item.remaining - 1 }); }, }); Updates to the database don't actually write to the transaction log or indexes immediately. Instead, the transaction accumulates them in its write set, which contains a map of each ID to the new value proposed by the transaction. In our example, the two calls to db.patch insert new versions of the cartItem._id and itemId documents into the write set. Let's summarize the state of our transaction once we're done executing addCart: * Begin timestamp: 16 * Read set: { get("items", itemId), query("carts.user_item", eq ("userToken", userToken), eq("itemId", itemId)) } * Write set: { cartItem._id: { ...cartItem, count: cartItem.count + 1 }, itemId: { ...item, remaining: item.remaining - 1 } } Commit protocol The committer in our system is the sole writer to the transaction log, and it receives finalized transactions, decides if they're safe to commit, and then appends their write sets to the transaction log. Let's process Alice's finalized transaction. The committer starts by first assigning a commit timestamp to the transaction that's larger than all previously committed transactions. Let's say in our example that a few transactions have committed concurrently with Alice's call to addCart at timestamps 17 and 18, so our commit timestamp will be 19. [dc3be57f330069703714473ad266f0693cdda3f7-1124x1172] We can check whether it's serializable to commit our transaction at timestamp 19 by answering the question, "Would our transaction have the exact same outcome if it executed at timestamp 19 instead of timestamp 16?"^11. One way to answer this question would be to rerun addCart from scratch at timestamp 19, but then we'd lose all concurrency in our system, returning to running just one transaction at a time. Instead, we can check whether any of the writes between the begin timestamp and commit timestamp overlap with our transaction's read set. For each log entry in this range, we see if the write would have changed the result of either our get("items", itemId) or query ("carts.user_item", ...) reads. [b2c6c8c76eba8c59fe7cd381cf1e97e24debe0dd-1124x1172] If none of these writes overlap, then database will look exactly the same whether addCart executed at timestamp 16 or the present timestamp 19. So, we can just pretend the addCart transaction actually happened at timestamp 19. This process of choosing a safe commit timestamp is typically referred to as "serializing" the transaction. What it means in practice is that multiple transactions are able to run safely simultaneously, and the final database state will look like they happened one at a time. Assuming we didn't find any overlapping writes, the committer pushes the transaction's write set onto the transaction log and returns successfully to the client. [f3acb829fe53e5bb37cdb58add171bf783a5d8d9-1076x1316] If, however, we found a concurrent write that overlapped with our transaction's read set, we have to abort the transaction. We rollback its writes by discarding its write set, and the committer throws an "Optimistic Concurrency Control" (or "OCC") conflict error to the function runner. This error signals that the transaction conflicted with a concurrent write and needs to be retried. The function runner will then retry addCart at a new begin timestamp past the conflict write. We'll see in a bit why it's always safe for the function runner to retry this mutation. [14cfb60f6ca534d485e320d61edeff97d95a390b-1124x1172] Subscriptions At this point, we've learned how Convex uses a custom-built database to provide strong consistency and high transaction processing throughput. But wait, there's more! We can also use read sets for implementing realtime updates for queries, where a user can subscribe to the result of a query changing. Let's return to our query getItems, which finds all items with remaining inventory. We track read sets when executing queries just like we do in mutations. // convex/getItems.ts import { query } from "./_generated/server"; export default query({ args: {}, handler: async ({ db }) => { const items = await db .query("items") .withIndex("remaining", (q) => q.gt("remaining", 0)) .collect(); // <=== Read set: `{ query(items.remaining, gt("remaining", 0)) }` return items; }, }); Queries don't go through the commit protocol, since they don't have any writes, but we can use their read sets for implementing subscriptions. Let's return to our React app to see how this works. // components/Items.tsx import { api } from "../convex/_generated/api"; import { useQuery } from "convex/react"; import { Item } from "./Item"; export function Items() { const items = useQuery(api.getItems.default) ?? []; return (