- Published on
A Brief Introduction to Convex
- Authors
- Name
- Cookie
Convex
I saw some cool videos about Convex in X recently and took a quick dive into it, tried to understand what it is and how it works under the hood.
What is Convex
Convex is a document-relational database platform that allows you to write database queries as reactive TypeScript functions that run inside the database itself.
Database Platform vs Database Engine
Traditional Database Engine like PostgreSQL or MongoDB offers only the data storage and query engine and the deployment, scaling, backups, monitoring, APIs are handled directly by the user.
On the other hand, a Database Platform abstracts away those concerns by integrating tools with the Database Engine. This is similar to an IDE compared to a Code Editor.
Reactive TS functions
You write instead of SQL, but TS, and Convex enables the re-execution of queries automatically when the underlying data changes. This is similar to how React components re-render when state changes.
Motivations behind Convex
Convex was created to solve the fundamental complexity of building real-time applications where the frontend needs to stay synchronized with backend data changes.
Traditional web development requires developers to manually manage complex state synchronization: writing custom WebSocket code, implementing caching layers, handling optimistic updates, managing loading states, and ensuring data consistency across multiple clients, which is both error-prone and time-consuming.
Convex eliminates this complexity by providing automatic reactivity which makes building real-time applications like chat apps, collaborative tools or live dashboards as simple as building static websites.
Quick Start
Running following command to set up the tutorial of a chat App.
git clone https://github.com/get-convex/convex-tutorial.git
cd convex-tutorial
npm install
npm run dev
After setup, Convex pulls up automatically the backend server and creates a folder convex/
in the project. All backend logic is located in this folder.
There are three main concepts used in Convex: “query”, “mutation” and “action”. As you can see in the following image.

Convex uses the words “query” and “mutation” for GET and POST methods. And the corresponding API functions for frontend can be used easily by wrapping them into useQuery
and useMutation
hook.
import { useEffect, useState } from "react";
import { faker } from "@faker-js/faker";
// import convex API
import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
// For demo purposes. In a real app, you'd have real user data.
const NAME = getOrSetFakeName();
export default function App() {
//const messages = [
// { _id: "1", user: "Alice", body: "Good morning!" },
// { _id: "2", user: NAME, body: "Beautiful sunrise today" },
//];
// Add mutation and query hooks
const messages = useQuery(api.chat.getMessages);
const sendMessage = useMutation(api.chat.sendMessage);
const [newMessageText, setNewMessageText] = useState("");
useEffect(() => {
// Make sure scrollTo works on button click in Chrome
setTimeout(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
}, 0);
}, [messages]);
return (
<main className="chat">
<header>
<h1>Convex Chat</h1>
<p>
Connected as <strong>{NAME}</strong>
</p>
</header>
{messages?.map((message) => (
<article
key={message._id}
className={message.user === NAME ? "message-mine" : ""}
>
<div>{message.user}</div>
<p>{message.body}</p>
</article>
))}
<form
onSubmit={async (e) => {
e.preventDefault();
// TODO
alert("Mutation not implemented yet");
setNewMessageText("");
}}
>
<input
value={newMessageText}
onChange={async (e) => {
const text = e.target.value;
setNewMessageText(text);
}}
placeholder="Write a message…"
autoFocus
/>
<button type="submit" disabled={!newMessageText}>
Send
</button>
</form>
</main>
);
}
function getOrSetFakeName() {
const NAME_KEY = "tutorial_name";
const name = sessionStorage.getItem(NAME_KEY);
if (!name) {
const newName = faker.person.firstName();
sessionStorage.setItem(NAME_KEY, newName);
return newName;
}
return name;
}
We can create some basic database operations for the chat App in backend folder convex/
. Convex generate the api
interface same as the file system structure in the convex/
folder.
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
export const sendMessage = mutation({
args: {
user: v.string(),
body: v.string(),
},
handler: async (ctx, args) => {
console.log("This TypeScript function is running on the server.");
await ctx.db.insert("messages", {
user: args.user,
body: args.body,
});
},
});
export const getMessages = query({
args: {},
handler: async (ctx) => {
// Get most recent messages first
const messages = await ctx.db.query("messages").order("desc").take(50);
// Reverse the list so that it's in a chronological order.
return messages.reverse();
},
});
It also offers a dashboard for managing logs, data storage, file storage, etc.
npx convex dashboard
Due to the limitation of automatic reactivity, query and mutation functions cannot make fetch
calls to the outside world. Thus, “action” is introduced to interact with the rest of the internet. Convex has a sync engine running at the backend that can write data from “action” back via mutations.
export const getWikipediaSummary = internalAction({
args: { topic: v.string() },
handler: async (ctx, args) => {
const response = await fetch(
"https://en.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exintro&explaintext&redirects=1&titles=" +
args.topic,
);
const summary = getSummaryFromJSON(await response.json());
await ctx.scheduler.runAfter(0, api.chat.sendMessage, {
user: "Wikipedia",
body: summary
})
},
});
function getSummaryFromJSON(data: any) {
const firstPageId = Object.keys(data.query.pages)[0];
return data.query.pages[firstPageId].extract;
}
// update sendMessage
export const sendMessage = mutation({
args: {
user: v.string(),
body: v.string(),
},
handler: async (ctx, args) => {
console.log("This TypeScript function is running on the server.");
await ctx.db.insert("messages", {
user: args.user,
body: args.body,
});
// getWikipediaSummary is defined as "internalAction", it can
// thus only be called internally (not on the client side)
// However, this might cause an infinite loop if the returned body
// also starts with "/wiki"
if (args.body.startsWith("/wiki")) {
const topic = args.body.slice(args.body.indexOf(" ") + 1)
await ctx.scheduler.runAfter(0, internal.chat.getWikipediaSummary, {topic})
}
},
});
How Does Convex Work Really?
The Reactivity mechanism on Backend
Convex implements reactivity through a sophisticated subscription-based system that track exactly which data each query reads, enabling previse invalidation when that data changes. Its dependency tracking system is based on Read-Set Tracking.
The Reactivity Mechanism on Frontend
Take the previous getMessages
as an example:
export const getMessages = query({
args: {},
handler: async (ctx) => {
// Get most recent messages first
const messages = await ctx.db.query("messages").order("desc").take(50);
// Reverse the list so that it's in a chronological order.
return messages.reverse();
},
});
This query
function registers the query with metadata for Convex runtime :
/**
* This is just a wrapper of internal query function to make it accessible to outside.
* QueryBuilder is just a internal type helpder. Where DataModel is just a type
* describing tables in a Convex project.
*/
type query: QueryBuilder<DataModel, "public">;
/**
* The returned structure of the query function
*/
export type RegisteredQuery<
Visibility extends FunctionVisibility,
Args extends DefaultFunctionArgs,
Returns,
> = {
isConvexFunction: true;
isQuery: true;
/** @internal */
invokeQuery(argsStr: string): Promise<string>;
/** @internal */
exportArgs(): string;
/** @internal */
exportReturns(): string;
/** @internal */
_handler: (ctx: GenericQueryCtx<any>, args: Args) => Returns;
} & VisibilityProperties<Visibility
When a client calls useQuery(api.chat.getMessages)
, the internal ConvexReactClient
will subscribes to query changes and calls the callback whenever results update.
watchQuery<Query extends FunctionReference<"query">>(
query: Query,
...argsAndOptions: ArgsAndOptions<Query, WatchQueryOptions>
): Watch<FunctionReturnType<Query>> {
const [args, options] = argsAndOptions;
const name = getFunctionName(query);
return {
onUpdate: (callback) => {
const { queryToken, unsubscribe } = this.sync.subscribe(
name as string,
args,
options,
);
const currentListeners = this.listeners.get(queryToken);
if (currentListeners !== undefined) {
currentListeners.add(callback);
} else {
// different components can watch the same query with the same arguements
// thus multiple callbacks to one queryToekn
this.listeners.set(queryToken, new Set([callback]));
}
// unsubscribe function
return () => {
if (this.closed) {
return;
}
const currentListeners = this.listeners.get(queryToken)!;
currentListeners.delete(callback);
if (currentListeners.size === 0) {
this.listeners.delete(queryToken);
}
unsubscribe();
};
},
// other methods...
};
}
The callbacks get called in the transition
method, which is triggered when the underlying BaseConvexClient
calls it by queries update.
class ConvexReactClient {
// ...
private transition(updatedQueries: QueryToken[]) {
ReactDOM.unstable_batchedUpdates(() => {
for (const queryToken of updatedQueries) {
const callbacks = this.listeners.get(queryToken);
if (callbacks) {
for (const callback of callbacks) {
callback();
}
}
}
});
}
}
When a client calls useMutation(api.chat.sendMessage)
,it delegates to the ConvexReactClient
’s mutation
method. The actual network request happens in the BaseConvexClient.mutation()
method, which handles the WebSocket communication with the Convex backend.
export function createMutation(
mutationReference: FunctionReference<"mutation">,
client: ConvexReactClient,
update?: OptimisticUpdate<any>,
): ReactMutation<any> {
function mutation(args?: Record<string, Value>): Promise<unknown> {
assertNotAccidentalArgument(args);
return client.mutation(mutationReference, args, {
optimisticUpdate: update,
});
}
// ...
return mutation as ReactMutation<any>;
}
Typescript Integration for Database Operations
Convex does not compile TypeScript to SQL. Instead, it uses a sophisticated runtime execution model:
Arguments Return value
┌───────────────────┐ ┌───────────────────┐
│ Convex Value (JS) │ │ Convex Value (JS) │
└───────────────────┘ └───────────────────┘
│ ▲
convexReplacer │
│ convexReviver
Browser ▼ │
┌──────────────────────────┐ ┌──────────────────────────┐
│ JSON-serializable object │ │ JSON-serializable object │
└──────────────────────────┘ └──────────────────────────┘
│ ▲
JSON.serialize │
│ JSON.parse
▼ │
┌─────────────┐ ┌─────────────┐
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ String ├ ─ ─ ─ ─ ─ ─ ─ ─ ┤ String ├ ─ ─ ─ ─ ─ ─ ─ ─ ─
└─────────────┘ └─────────────┘
│ ▲
serde::Deserialize │
│ serde::Serialize
▼ │
┌──────────────────────┐ ┌──────────────────────┐
Rust │ Convex Value (Rust) │ │ Convex Value (Rust) │
└──────────────────────┘ └──────────────────────┘
│ ▲
serde::Serialize │
│ serde::Deserialize
▼ │
┌────────────────┐ ┌────────────────┐
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ String │─ ─ ─ ─ ─ ─ ─ ┤ String │─ ─ ─ ─ ─ ─ ─ ─ ─
└────────────────┘ └────────────────┘
│ ▲
JSON.parse │
│ JSON.serialize
▼ │
┌──────────────────────────┐ ┌──────────────────────────┐
│ JSON-serializable object │ │ JSON-serializable object │
└──────────────────────────┘ └──────────────────────────┘
│ ▲
convexReviver │
V8 │ convexReplacer
▼ │
┌───────────────────┐ ┌───────────────────┐
│ Convex Value (JS) │ │ Convex Value (JS) │
└───────────────────┘ └───────────────────┘
│ ▲
│ │
│ ┌─────────────────────┐ │
│ │ │ │
└───▶│ User UDF code │────┘
│ │
└─────────────────────┘
Convex uses a bridge mechanism(syscalls) that allows JavaScript code running in V8 to call Rust functions:
JavaScript: Memory: Rust:
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ "insert" │ → │ String │ → │ op_name │
│ {text: "Hello"} │ → │ JSON String │ → │ args_v │
│ Promise │ ← │ Promise │ ← │ PromiseResolver │
└─────────────────┘ └──────────────┘ └─────────────────┘
pub fn async_syscall(
&mut self,
args: v8::FunctionCallbackArguments,
mut rv: v8::ReturnValue,
) -> anyhow::Result<()> {
if args.length() != 2 {
anyhow::bail!("asyncSyscall(op, arg_object) takes two arguments");
}
// Extract the operation name, like "1.0/insert"
let op_name: v8::Local<v8::String> = args.get(0).try_into()?;
// convert it from v8's JS string format to Rust's string format
let op_name = to_rust_string(self, &op_name)?;
// Extract operation arguments
let args_v8: v8::Local<v8::String> = args.get(1).try_into()?;
let args_s = to_rust_string(self, &args_v8)?;
// parse the JSON string into a Rust JSON object
let args_v: JsonValue = serde_json::from_str(&args_s).map_err(|e| {
anyhow::anyhow!(ErrorMetadata::bad_request(
"SyscallArgsInvalidJson",
format!("Received invalid json: {e}"),
))
})?;
// Create a Promise for async operation, 'resolver' is a local reference
let resolver = v8::PromiseResolver::new(self)
.ok_or_else(|| anyhow!("Failed to create PromiseResolver"))?;
// Get the actual Promise object that JS wil receive
let promise = resolver.get_promise(self);
// Creates a global reference that prevents garbage collection
let resolver = v8::Global::new(self, resolver);
// Tell the environment to start the async operation
{
let state = self.state_mut()?;
state
.environment
.start_async_syscall(op_name, args_v, resolver)?;
}
rv.set(promise.into());
Ok(())
}
There is a “chicken and egg” question automatically coming up by this approach: how do JS and Rust agree on memory location in the first place? (Where to put the data in memory?)
The answer is that the underlying V8 engine handles all memory logistics:
// During Convex startup (in Rust), pseudocode
fn setup_javascript_bridge() {
// 1. Create a V8 JavaScript context
let context = v8::Context::new();
// 2. Register Rust functions with V8
let global = context.global();
// 3. Create a function template that points to our Rust function
let async_syscall_template = v8::FunctionTemplate::new(scope, async_syscall);
// 4. Bind it to a JavaScript name
global.set("asyncSyscall", async_syscall_template);
}
The next key challenge is: how does Rust understand what database operations to perform based on TypeScript code?
When you write TypeScript like this:
await ctx.db.insert("messages", {text: "Hello", user: "John"});
You're not writing "insert instructions" that Rust has to interpret. You're calling a predefined JavaScript function that translates to a specific Rust operation.
What under the hood might be:
await performAsyncSyscall("1.0/insert", {
table: "messages",
value: {text: "Hello", user: "John"}
});
Rust has a predefined set of operations it knows how to handle, and each operation has a predefined JSON schema that Rust expects:
pub async fn run_async_syscall_batch(
provider: &mut P,
batch: AsyncSyscallBatch,
) -> Vec<anyhow::Result<String>> {
let results = match batch {
AsyncSyscallBatch::Reads(batch_args) => Self::query_batch(provider, batch_args).await,
AsyncSyscallBatch::Unbatched { name, args } => {
let result = match &name[..] {
"1.0/insert" => Box::pin(Self::insert(provider, args)).await,
"1.0/patch" => Box::pin(Self::patch(provider, args)).await,
"1.0/delete" => Box::pin(Self::delete(provider, args)).await,
"1.0/replace" => Box::pin(Self::replace(provider, args)).await,
"1.0/queryStream" => Box::pin(Self::query_stream(provider, args)).await,
"1.0/count" => Box::pin(Self::count(provider, args)).await,
// ... more operations
_ => anyhow::bail!("Unknown syscall: {name}"),
};
vec![result]
},
};
}
Summary
Convex has created a developer experience that makes building real-time applications genuinely simple. The technical sophistication behind this simplicity is remarkable - from the V8/Rust bridge to the precise reactivity system.
However, as with any platform decision, the trade-offs between developer productivity and performance/flexibility need careful consideration for each use case.
This introduction has uncovered the core mechanisms of Convex’s reactivity system and TypeScript-Rust integration, but several areas remain unexplored: detailed read-set implementation, the security model for user code isolation, the performance optimization strategies and etc.
At the end, I really appreciate the philosophy behind it: a well-designed system makes it easy to do the right things and annoying (but not impossible) to do the wrong things.