---
title: 'Improving JavaScript Webhook Transformations'
authors: ['jbrown']
date: 2025-12-09T12:00:00
tags: ['technical', 'rust', 'javascript']
summary: "Some technical details about the implementation of Svix's 'transformations' feature and the migration from Deno/v8 to QuickJS"
---

![Cover image](./cover.png)

<div className="lead">

Svix is the enterprise ready webhooks sending service. With Svix, you can build a secure,
reliable, and scalable webhook platform in minutes. Looking to send, receive, and transform webhooks?
[Give Svix a try!](https://www.svix.com)

</div>

One of the most powerful features of Svix is <q>[Transformations](https://docs.svix.com/transformations)</q>, which allows
customers to provide JavaScript[^javascript] snippets that are executed prior to delivery of a webhook; these can change the body, URL,
or headers of the webhook, and easily adapt it for delivery into their target environment.

A simple transformation might look like the following:

```javascript
function handler(webhook) {
  webhook.headers = {
    'X-My-Authentication-Secret': 'hunter2',
  }
  if (webhook.payload.fruit == 'bananas') {
    webhook.payload.fruit = 'plantains'
  }
  return webhook
}
```

<aside>

📖 You can learn more about the hows and whys of transformations in our [release blog post](../transformations-feature/).

</aside>

When we originally set out to build this feature, we used the best-known of the various JavaScript interpreters:
Google's [v8](https://v8.dev/), via the [deno_core](https://github.com/denoland/deno_core) project. v8 is an amazing
piece of technology; a modern optimizing JIT compiler that can turn highly-dynamic JavaScript into efficient machine code.
deno provided us with all of the important intrinsics we needed, wrapped in a fairly ergonomic Rust package. That being said,
some parts of using Deno/v8 weren't a very good fit for us:

- v8 is optimized for long-running pieces of code which can benefit from its extensive [just-in-time compiler](https://en.wikipedia.org/wiki/Just-in-time_compilation); most of our functions are only executed a single time in the lifespan of an isolate. There's a big cost to run such a complicated interpreter and end up running relatively few instructions
- Deno doesn't have any native way to limit the execution time of a script
- The memory-limitation features of v8 are pretty rough. You can [configure heap limits](https://docs.rs/v8/latest/v8/struct.CreateParams.html#method.heap_limits) on a v8 instance, but if a program exceeds those limits, the interpreter ends up crashing and dumping a bunch of state to stderr, which is not ideal for a server application.

We worked around the latter two of these by periodically using the [`IsolateHandle.request_interrupt`](https://docs.rs/v8/latest/v8/struct.IsolateHandle.html#method.request_interrupt) function, measuring the heap usage and CPU usage from a (very carefully constructed) `extern "C"` callback function, and killing the transformation if it exceeded pre-configured limits.
This code was fragile and tended to break on every upgrade; for a scary example of this, see [rusty_v8#1883](https://github.com/denoland/rusty_v8/issues/1883).
The first problem, though, was the biggest one &mdash; our average runtime for a transformation using v8 was over 10 milliseconds, around 90% of which was spent constructing all the scaffolding and initializing the Isolate.

As of today, we've now completely switched over to a new transformation engine based on [QuickJS](https://bellard.org/quickjs/)[^quickjs-ng], a lightweight
JavaScript interpreter designed for fast startup time.
We're using it through the [rquickjs](https://github.com/DelSkayn/rquickjs) Rust bindings.
Let's start off with the important picture:

![histogram comparing performance of deno and rquickjs](perf.svg)

The response time with QuickJS dropped from 11.0±7.0ms to 1.0±0.7ms: more than 10x faster, and with commensurately less variance.
Sweet! Performance improvements in debug mode are even more impressive (over 14x in local benchmarking), which is a big help
for Svix developers working on this feature.

## Building with rquickjs

QuickJS isn't just fast to execute, but it was very fast to build and roll out.
Maintaining our invariants is also a lot easier than it was with Deno, thanks to the
[AsyncRuntime::set_memory_limit](https://docs.rs/rquickjs/latest/rquickjs/struct.AsyncRuntime.html#method.set_memory_limit)
and the much simpler [AsyncRuntime::set_interrupt_handler](https://docs.rs/rquickjs/latest/rquickjs/struct.AsyncRuntime.html#method.set_interrupt_handler) methods. These are very easy to use:

```rust
let runtime = rquickjs::AsyncRuntime::new()?;
runtime.set_memory_limit(memory_limit).await;
let start = Instant::now();
runtime
    .set_interrupt_handler(Some(Box::new(move || start.elapsed() > max_duration)))
    .await;
```

That's all you need to do to get a runtime with a bounded memory usage and a reasonably-bounded execution time[^bounded].

Executing some code in this isolated runtime is equally straightforward:

```rust
let context = rquickjs::AsyncContext::full(&runtime)
    .await?;
let value = rquickjs::async_with!(context => |ctx| {
    // EvalOptions is `#[non_exhaustive]`
    let options = {
        let mut options = EvalOptions::default();
        options.global = true;
        options.strict = false;
        options.promise = true;
        options
    };

    let value = ctx.eval_with_options::<Promise<'_>, String>(
        script,
        options
    )
    .catch(&ctx)?
    .into_future::<rquickjs::Value>()
    .await
    .catch(&ctx)?;

    let stringified = ctx
        .json_stringify(value)?;
    if let Some(stringified) = stringified {
        serde_json::from_str(&stringified)
    } else {
        Err(ScriptError::NoOutput)
    }
});
```

A couple of things worth noting here:

1.  Everything is wrapped in [rquickjs::async_with!](https://docs.rs/rquickjs/latest/rquickjs/macro.async_with.html); there are lots of different ways to get a [Ctx](https://docs.rs/rquickjs/latest/rquickjs/struct.Ctx.html) object in this library, but this is the simplest.
1.  We're disabling strict mode[^strict], and enabling promises. You still can't write `async` handler scripts[^yet], but this is required for our extension modules.
1.  We're mixing [serde_json](https://docs.rs/serde_json/latest/serde_json/) and rquickjs for input/output.[^rquickjs_serde]

To safely roll out this change, we modified our application to run every single transformation through v8 and QuickJS concurrently,
and report any errors (and to return the v8 version if they differed). After a couple of days of cleanup and monitoring, we switched
all traffic to use QuickJS on December 8.

## The Future

While QuickJS has been a great library for improving this offering, we're always keeping our eye on new development in this space &ndash;
maybe [BoaJS](https://boajs.dev) will be a safer replacement as it develops. I'm also very interested in
[WebAssembly Components](https://component-model.bytecodealliance.org) and think it would be pretty neat to allow our customers
to write their transformation functions in the language of their choice and just ship us some wasm32 bytecode.

Stay tuned at [https://www.svix.com/blog](https://www.svix.com/blog) to hear more about this work! Be
sure to follow us on [Github](https://github.com/svix) or [RSS](https://www.svix.com/blog/rss/) for the latest updates for the [Svix webhook service](https://www.svix.com), or join the discussion on [our community Slack](https://www.svix.com/slack/).

Are you also interested in building safe platforms for execution of customer code? [Come work with us!](https://www.svix.com/careers/)

[^javascript]: a.k.a. [ECMAScript](https://ecma-international.org/publications-and-standards/standards/ecma-262/)

[^quickjs-ng]: Technically, the [QuickJS-NG](https://quickjs-ng.github.io/quickjs/) fork

[^bounded]:
    Technically, the interrupt handler only runs periodically based on some internal cost heuristics in
    QuickJS, so it's possible for a request to take longer than the expected duration

[^strict]: QuickJS enables [strict mode](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode) by default. Probably, everybody should be using strict mode all the time, but we have a number of customers who rely on setting variables without `let`, `var` or `const`, so we have to enable sloppy-mode.

[^yet]: Well, not yet...

[^rquickjs_serde]:
    A potential future improvement is to directly deserialize from the QuickJS-NG data structures instead of
    stringifying and using `serde_json`; there's a
    library named [rquickjs_serde](https://docs.rs/rquickjs-serde/latest/rquickjs_serde/) that does this (which isn't quite
    in a production-ready state yet), and we look forward to saving a few CPU cycles with it in the future.
