This is an excerpt from Happy Little Monoliths (Chapter 6). You can find the source code for the Fastify example here.

Now that we've seen how to efficiently parse and serialize JSON using a number of strategies, it's time to take a look at Cap'n Proto. This is a lean and fast data interchange format developed by Kenton Varda, the author of Protocol Buffers who is, at the time of writing, a technical lead for the Cloudflare Workers project.

Cap'n Proto is now used extensively at Cloudflare.

Remember when Protocol Buffers first came out and we thought it was going to make things better? My impression is that gRPC and Protocol Buffers never really took off for JavaScript applications.

Surely with some effort one could find real world applications leveraging them — or blog posts and tech conference presentations by eager developers, but it never actually landed in my practice. Over the past 10 years, I've touched exactly one application that used gRPC and Protocol Buffers.

As I was writing the final chapters of this book, Pooya Parsa released capnp-es, forked from the now 4-year old capnp-ts. Pooya covered a lot of ground in fine tuning this library, including upgrading to TypeScript v5, getters and setters for property access and preliminary RPC support, among many others.

This inspired me to give it a try, and I was rather intrigued by it.

Cap'n Proto requires a precompiled schema to exist in order to work with data, data which is encoded in a binary format. This results in much lighter payloads, and — in theory — much faster parsing time. Mainly because in Cap'n Proto there's no parsing, all data is extracted on-demand using pointers.

Sounds great, right? Well, yes, but there's a catch, as we'll soon find out.

A Simple Example

Let's create a simple Cap'n Proto message.

First we need a schema. You can use capnp-schema-gen for this.

Considering a basic.json file as follows:

{
  "name": "John Doe",
  "email": "johndoe@acme.fake"
}

You can generate a Cap'n Proto schema as follows:

cat basic.json | npx capnp-schema-gen Basic > basic.capnp

This is what it looks like:

@0xa9b3e913b8d2bf49;

struct Basic {
  name @0 :Text;
  email @1 :Text;
}

Next we have to compile the schema class, via capnp-es:

npx capnp-es -ojs basic.capnp 

Which should create a file like this:

// This file has been automatically generated by capnp-es.
import * as $ from "capnp-es"
export const _capnpFileId = BigInt("0xd7b95c53e5042a1b")
export class Basic extends $.Struct {
  static _capnp = {
    displayName: "Basic",
    id: "be3c40e060e06605",
    size: new $.ObjectSize(0, 2)
  }
  get name() {
    return $.utils.getText(0, this)
  }
  set name(value) {
    $.utils.setText(0, value, this)
  }
  get email() {
    return $.utils.getText(1, this)
  }
  set email(value) {
    $.utils.setText(1, value, this)
  }
  toString() {
    return "Basic_" + super.toString()
  }
}

Examples are available in examples/6/basic-capnp.

Sending Cap'n Proto Data

Next, let's try and make it practical — we'll build a simple UI on the client to encode and send data to the server using Cap'n Proto.

Well, don't get too excited about the UI, it'll be just a button. But it's enough for what we're trying to do here.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="/index.css">
  <title>Send Cap'n Proto</title>
</head>
<body>
  <button id="sendBtn">Send</button>
  <script type="module" src="/index.js"></script>
</body>
</html>

And then we'll do a basic @fastify/vite SPA setup:

import Fastify from 'fastify'
import FastifyVite from '@fastify/vite'

async function getServer () {
  const server = Fastify()

  await server.register(FastifyVite, {
    root: import.meta.url,
    spa: true
  })

  server.get('/', (_, reply) => reply.html())

  await server.vite.ready()

  return server
}

if (process.argv[1] === new URL(import.meta.url).pathname) {
  const server = await getServer()
  await server.listen({ port: 3000 })
}

And a little script to send the data over:

import { Basic } from '../basic'
import { Message } from 'capnp-es'

document.getElementById('sendBtn')
  .addEventListener('click', async () => {

    const message = new Message()
    const obj = message.initRoot(Basic)
    obj.name = 'John Doe'
    obj.email = 'johndoe@acme.org'

    const res = await fetch('/decode', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-capnp'
      },
      body: message.toArrayBuffer()
    })

    const data = await res.json()
    console.log('Name:', data.name)
    console.log('Email:', data.email)
  })

Notice how we use toArrayBuffer() on the capnp.Message instance. You could also use toPackedArrayBuffer(), which saves up space by doing a moderate level of compression on things like zeros, and adds a bit of overhead as well.

When loading a buffer, that's what the second parameter of the capnp.Message constructor is for — to determine whether or not you're using the packed variant.

Receiving Cap'n Proto Data

Next we proceed to add an /encode endpoint that will use the Basic schema class to decode the message. Bear in mind, there's no actual decoding going on, we're just loading data into a class with carefully crafted methods able to instantly retrieve pieces of it.

import Fastify from 'fastify'
import FastifyVite from '@fastify/vite'
import * as capnp from 'capnp-es'
import { Basic } from './basic.js'

async function getServer () {
  const server = Fastify()

  await server.register(FastifyVite, {
    root: import.meta.url,
    spa: true
  })

  server.get('/', (_, reply) => reply.html())

  server.addContentTypeParser('application/x-capnp', { 
    parseAs: 'buffer'
  }, (_, body, done) => done(null, body))

  server.post('/decode', async (req, reply) => {
    const message = new capnp.Message(req.body, false);
    const obj = message.getRoot(Basic)
    reply.send({ 
      name: obj.name, 
      email: obj.email
    })
  })

  await server.vite.ready()

  return server
}

It's unclear what the official MIME type for Cap'n Proto messages is, but I've seen a lot of people using application/x-capnp.

This is of course just a detail, application/octet-stream could work just as fine, depending on how you have your Fastify content type parsers set up.

This example is available in examples/6/fastify-capnp.

Is Cap'n Proto Event Loop Friendly?

Now, if all you're doing is sending a couple of strings, needless to say, Cap'n Proto is not worth it. In fact, the payload ends up being actually bigger than JSON in that case. The use of the Basic schema in the previous examples served educational purposes only. Let's see how well Cap'n Proto does when dealing with larger payloads. To help us test, I've created a generate.js script to build a large JSON with a large list. Let's call it News JSON:

import { LoremIpsum } from 'lorem-ipsum'
import slugify from 'slugify'

const lorem = new LoremIpsum()

const news = {
  highlights: [],
  stories: [],
  date: new Date().toUTCString()
};

for (let i = 0; i < 10; i++) {
  news.highlights.push({
    title: lorem.generateSentences(1),
    link: slugify(lorem.generateSentences(1))
  })
}

for (let i = 0; i < 100; i++) {
  const commentId = crypto.randomUUID()
  news.stories.push({
    title: lorem.generateSentences(1),
    link: slugify(lorem.generateSentences(1)),
    comments: [{
      id: commentId,
      text: lorem.generateSentences(1),
      link: `comments/${commentId}`,
    }]
  });
}

console.log(JSON.stringify(news, null, 2))

This will generate a 46kb JSON file.

With that, we can create all we need for more testing:

node generate.js > news.json
cat news.json | npx capnp-schema-gen News > news.capnp
npx capnp-es -ojs news.capnp

Next we'll create a script to write a file with a buffer containing news.json encoded as a Cap'n Proto message. This is just so we can skip this part in the decoding test.

import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import * as capnp from 'capnp-es'
import { News } from './news.js'

writeFileSync(
  join(import.meta.dirname, 'news.buffer'),
  Buffer.from(
    createMessage(
      JSON.parse(
        readFileSync(join(import.meta.dirname, 'news.json'), 'utf8')
      )
    )
  )
)

function createMessage(data) {
  const message = new capnp.Message()
  const obj = message.initRoot(News)
  const highlightsList = obj._initHighlights(data.highlights.length)
  for (let i = 0; i < data.highlights.length; i++) {
    const highlightsItem = highlightsList.get(i)
    highlightsItem.link = data.highlights[i].link
  }
  const storiesList = obj._initStories(data.stories.length)
  for (let i = 0; i < data.stories.length; i++) {
    if (data.stories[i] !== null && typeof data.stories[i] === 'object') {
      const storiesItem = storiesList.get(i)
      storiesItem.title = data.stories[i].title
      storiesItem.link = data.stories[i].link
      const commentsList = storiesItem._initComments(
        data.stories[i].comments.length
      )
      for (let k = 0; k < data.stories[i].comments.length; k++) {
        const commentsItem = commentsList.get(k);
        commentsItem.id = data.stories[i].comments[k].id
        commentsItem.text = data.stories[i].comments[k].text
        commentsItem.link = data.stories[i].comments[k].link
      }
    }
  }
  obj.date = data.date
  return message.toArrayBuffer()
}

This also serves to illustrate an important aspect of working with Cap'n Proto messages, which is populating arrays.

The generated schema class will have these _init methods which can be used to initialize the list (setting its size), and then we proceed to grab the preexisting object allocated for the list and use get(index) to retrieve and set each item:

  const highlightsList = obj._initHighlights(data.highlights.length)
  for (let i = 0; i < data.highlights.length; i++) {
    const highlightsItem = highlightsList.get(i)
    highlightsItem.link = data.highlights[i].link
  }

Moving on, let's set up a simple server to decode payloads both in JSON and Cap'n Proto. This should allow us to fire up autocannon against each endpoint.

import Fastify from 'fastify'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import * as capnp from 'capnp-es'
import { News } from './news.js'

const buffer = readFileSync(join(import.meta.dirname, 'news.buffer'))
const string = readFileSync(join(import.meta.dirname, 'news.json'))

async function getServer() {
  const server = Fastify()
  server.get('/capnp', (_, reply) => {
    const message = new capnp.Message(buffer, false)
    const { date } = message.getRoot(News)
    reply.send({ date })
  })
  server.get('/json', (_, reply) => {
    const { date } = JSON.parse(string)
    reply.send({ date })
  })
  return server
}

if (process.argv[1] === new URL(import.meta.url).pathname) {
  const server = await getServer()
  await server.listen({ port: 3000 })
}

And these are our results:

autocannon /capnp

25100 rps

autocannon /json

11758 rps

For a 64kb JSON file, that's over 100% improvement.

So, there must be something off, right? Well, yes — as you noticed, we're only accessing one field, date. If you were to try to access all fields:

for (const highlightsItem of obj.highlights) {
  void highlightsItem.link
}
for (const storiesItem of obj.stories) {
  void storiesItem.title
  void storiesItem.link
  for (const commentsItem of storiesItem.comments) {
    void commentsItem.id
    void commentsItem.text
    void commentsItem.link
  }
}

Things are actually radically different:

autocannon /capnp

900 rps

autocannon /json

11600 rps

As you can see, accessing all fields in the JSON-based implementation has no overhead, because all data is eagerly loaded into a JavaScript object.

For Cap'n Proto, that means calling, for instance, this function (and all the ones it calls) every time a field is accessed:

export function getPointer(index: number, s: Struct): Pointer {
  checkPointerBounds(index, s)
  const ps = getPointerSection(s)
  ps.byteOffset += index * 8
  return new Pointer(ps.segment, ps.byteOffset, s._capnp.depthLimit - 1)
}

And there we have the explanation for the massive drop in performance when acessing all fields. Which brings us to this conclusion:

For consuming large data payloads for deferred processing, Cap'n Proto can be seen as a major advantage in scaling Node.js applications, but its benefits are limited to the reduction in payload size only.

There are two more instances where it could make sense:

  • Your payload is exceedingly large, as in, several megabytes. For the same news.json file expanded with millions of items up to 40mb, encoding in Cap'n Proto makes the payload nearly half in size at 21mb. The speed gains from the reduced payload size can perhaps outweigh the overhead from consuming the data. Profile thoroughly to find out.

  • Not all incoming data needs to be accessed — in that case same as above, it's worth profiling and figuring out if it's a good fit.

For everything else, stick to JSON and application/x-www-form-urlencoded.

Special thanks to Matteo Collina for helping investigate this.

Before we wrap up, it's worth noting a couple of additional caveats:

It should also be noted capnp-es is still in alpha.

However in my testing it seems stable enough so far.