Published on

A Brief Introduction to Convex

Authors
  • avatar
    Name
    Cookie
    Twitter

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.

convexArchitecture

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.