website/src/content/posts/2025-09-24-vbare-simple-schema-evolution-with-maximum-performance/page.mdx
At Rivet, we're building an open-source alternative to Cloudflare Durable Objects — a tool for running stateful compute workloads. VBARE is a small but crucial component in meeting the demanding performance requirements of Rivet Actors.
We decided to adopt Protocol Buffers for any internal communications & data stored at rest. We've always believed it's a flawed technology, but it was the only mainstream and portable tool that provided binary serialization and schema evolution — or what we thought would be sufficient schema evolution.
Issues started arriving after our v1 launched and we started adding new features that required significant changes to the datastructures in our Protobufs. Eventually, all of our Protobuf files started generating bitrot of sorts. They became tedious to read, understand defaults, and understand migration paths. We frequently ended up with a handful of common issues:
More importantly, the more we changed in Protobuf, more and more cruft had to be added to our application logic that made adding features a tedious process.
To solve this, we started following this pattern to allow us to clean up our application logic:
This became such a standard practice that most of our API saw a turnover to a new version once every 3-6 months.
While this process helped simplify our application logic significantly, it required a lot of manual effort to implement the Protobuf migrations. So we started evaluating other options that might provide better schema evolution.
We evaluated numerous serialization protocols before deciding to build VBARE. Here's a brief overview of why each fell short:
(For a detailed comparison, see our full evaluation document.)
In the end, none of these solutions provided a compelling schema evolution mechanism.
Enter, VBARE: a tiny extension of BARE to provide a version header and handle version migrations.
Instead of using an off-the-shelf solution, we opted to build a simple evolution system similar to the pattern we were already using: manually writing code to migrate between schemas. We would pair this technique with the BARE encoding to create VBARE.
<Info> [BARE (Binary Application Record Encoding)](https://baremessages.org/) — which VBARE extends — is a simple binary serialization format designed for efficiency and simplicity. Unlike self-describing formats like JSON or CBOR, BARE requires a schema to encode and decode data, similar to Protocol Buffers but with a much simpler design philosophy. </Info>VBARE's design philosophy centers on four core beliefs:
Make smaller, incremental schema changes instead of massive v1 to v2 overhauls. Build tools that make this easy and your schema will be easier to work with.
Manual evolution simplifies application logic by isolating schema evolution logic to a separate module before passing it to the application logic.
Real-world schema evolutions require more than simple property renaming: it involves complex reshaping and fetching data from remote sources, which automatic migration systems can't handle.
Manual evolution is less error prone by forcing developers to explicitly handle edge cases of migrations and breaking changes, trading verbosity for safety.
VBARE operates by declaring a schema file for every version of your protocol, then writing explicit conversion functions between adjacent versions.
Each message has an associated version number (unsigned 16-bit integer) that increases monotonically from 1. Versions are specified in the filename: my-schema/v1.bare, my-schema/v2.bare, etc.
Servers define conversion code that transforms between versions bidirectionally:
There are no evolution semantics in the schema language itself. To create a new version, you simply copy the previous schema and make your changes.
Every message has an associated version, which can be either:
POST /v3/users), query parameters, or handshakesServers must include converters between all versions to handle any client version.
Clients only need to include a single version since the server handles all version conversion.
Common use cases include:
For examples, VBARE powers almost all of Rivet:
Here's a simple example demonstrating how VBARE handles a complex schema migration that is not possible with any existing tools:
type User struct {
id: string
name: string
}
type User struct {
id: string
// Split `name` in to 2 properties
firstName: string
lastName: string
}
import * as V1 from "./v1";
import * as V2 from "./v2";
import { createVersionedDataHandler } from "vbare";
// Converter from v1 to v2
function upgradeUserV1ToV2(v1: V1.User): V2.User {
const [firstName, ...rest] = v1.name.split(' ');
return {
...v1,
firstName,
lastName: rest.join(' ') || ''
};
}
// Converter from v2 to v1
function downgradeUserV2ToV1(v2: V2.User): V1.User {
return {
...v2,
name: `${v2.firstName} ${v2.lastName}`.trim()
};
}
// Create versioned data handler
export const USER_VERSIONED = createVersionedDataHandler<V2.User>({
deserializeVersion: (bytes: Uint8Array, version: number): any => {
switch (version) {
case 1:
return V1.decodeUser(bytes);
case 2:
return V2.decodeUser(bytes);
default:
throw new Error(`invalid version: ${version}`);
}
},
serializeVersion: (data: any, version: number): Uint8Array => {
switch (version) {
case 1:
return V1.encodeUser(data);
case 2:
return V2.encodeUser(data);
default:
throw new Error(`invalid version: ${version}`);
}
},
deserializeConverters: () => [upgradeUserV1ToV2],
serializeConverters: () => [downgradeUserV2ToV1],
});
For a full list of BARE implementations, visit baremessages.org.
RPC interfaces are trivial to implement yourself. Libraries that provide RPC interfaces tend to add extra bloat and cognitive load through things like abstracting transports, compatibility with the language's async runtime, and complex codegen to implement handlers.
Usually, you just want a ToServer and ToClient union that looks like this:
Migration steps are fairly minimal to write. The most verbose migration steps will be for deeply nested structures that changed, but even that is relatively straightforward.
VBARE is open source and available on GitHub.
See the guides for getting started with TypeScript and Rust.