JavaScript on Wasm: The Shopify Functions Story

Shopify Functions let developers inject custom code that runs on Shopify's servers, with WebAssembly (Wasm) as the underlying execution layer. Since Summer Editions 2022, Rust has been the recommended language for building Functions. But the majority of Shopify's developer ecosystem writes JavaScript, so the team set out to make JavaScript a first-class option as well. The Winter Editions 2023 announcement included a Local Developer Preview for JavaScript Shopify Functions, allowing developers to run functions locally while the infrastructure for public deployment is still being finalized.

The technical challenge is significant: every Shopify Function runs as a WASI (WebAssembly System Interface) module, subject to strict constraints. Wasm modules are sandboxed and can only perform arithmetic on an isolated chunk of memory unless the host explicitly exposes additional capabilities. WASI standardizes those capabilities to make Wasm useful outside the browser, and Shopify Functions rely on a minimal slice of it: reading from stdin and writing to stdout (plus stderr).

Every module must also satisfy three hard limits:

  1. The module size must not exceed 256KB.
  2. Execution must complete within 5ms.
  3. The module consumes a JSON-formatted string via stdin and produces a JSON-formatted string on stdout.

The 5ms limit is machine-dependent and situational—the same function can take different amounts of time on the same hardware depending on load. The team is exploring a gas-like measurement approach to provide a more deterministic, machine-independent way for developers to gauge whether their function will be fast enough.

The Javy Approach

The straightforward way to run JavaScript in Wasm would be to compile an existing engine like V8 or SpiderMonkey to a Wasm module. That doesn't work today because those high-performance engines depend on just-in-time (JIT) compilation. Wasm's architecture makes JIT impossible: the module cannot generate or execute new code at runtime because its instruction memory is completely inaccessible. This is a deliberate security design—it prevents any code that wasn't present at instantiation from running, eliminating a whole class of remote code execution attacks. But it rules out the standard JavaScript performance playbook.

The practical alternative is to embed a fast interpreter, compile it to Wasm, and ship the JavaScript code alongside the engine in the same binary. That's what Javy does. Built by Shopify's Saúl Cabrera, Javy is a JavaScript-to-WebAssembly toolchain intended as a general-purpose utility—no Shopify-specific code ships inside it.

QuickJS as the Engine

Javy's engine of choice is QuickJS, Fabrice Bellard's compact JavaScript engine. QuickJS passes the ES2020 test suite and is written in plain C, so it compiles cleanly to Wasm. Shopify's internal preference for Rust led to Rust wrappers around QuickJS: quickjs-wasm-sys and quickjs-wasm-rs.

The build process is unconventional and happens in two stages. First, a small Rust program compiles to a Wasm module. That program uses the QuickJS crates to instantiate the QuickJS engine inside the module and wires up stdin/stdout. Second, the Javy CLI is compiled, bundling the resulting Wasm binary.

When a developer runs the CLI, it uses Wizer to let QuickJS translate the developer's JavaScript into QuickJS bytecode. Wizer snapshots the state, producing a new WebAssembly/WASI module that contains the bytecode. Executing that module with Wasmtime runs the bytecode inside the QuickJS VM.

Size and the 256KB Ceiling

The approach works functionally, but the resulting Wasm binary is at least 800KB—well over the 256KB limit for Shopify Functions. Trimming the engine by removing features like the parser, RegExp support, ArrayBuffers, and other potentially unused pieces was considered. The problem is twofold: cutting those features creates an almost-JavaScript runtime that would frustrate developers, and even the most aggressive cuts only brought the size down to around 350KB, still above the threshold.

Getting JavaScript to run under these constraints required a different strategy to close the gap between what's possible and what's permissible on the Functions infrastructure. The work on Javy is ongoing, and feedback on the runtime and developer experience is being collected through the Shopify Functions in JavaScript repository.

What’s in the Box

With the static approach in place from the first iteration of Javy, we had a working path from JavaScript to WebAssembly. But that path came with some baggage: every module someone generated for their Shopify Function included a full copy of the QuickJS engine. So we started to look at a technique that we were already familiar with from building native binaries—dynamic linking.

WebAssembly does not sport features for dynamic linking yet, though it is an active area of research and standardization. The Component Model proposal is likely to progress through the stages and paints a promising future, but until runtime support exists in Wasmtime, we needed a solution for dynamic linking that doesn’t preclude us from adopting the mature proposal later.

Splitting the Script

Shopify’s Jeff Charles made use of Wasmtime’s linker—the mechanism that connects and resolves the imported items of a Wasm module with the exported items from a host system or other modules. Wasmtime also exposes this functionality to its CLI via the --preload flag. Building on this, we defined a minimal interface for a dynamic library and provider called javy_quickjs_provider_v1 that houses only two functions:

  • realloc(old_ptr, old_size, alignment, new_size): allocates, resizes or frees memory allocations.
  • eval_bytecode(ptr, size): runs the bytecode in a fresh QuickJS instance.

We modified our existing small module to fit the new interface requirements. By default, Javy continues to emit statically linked binaries (as generated via the process described earlier) that run with Wasmtime out of the box. Enabling the dynamic linking mode is a flag away with -d. When a JavaScript program gets handed to Javy, the runtime and crates like quickjs-wasm-sys transform the JavaScript to bytecode. Javy then generates a minimal, hand-crafted Wasm module that declares the imports needed for the provider library and calls eval_bytecode with the embedded bytecode:

We also embed the original source code into a Wasm custom section—a section without any effect on execution, but that lives in the module as a marker for which of the Javy runtime APIs are in use.

The compiled WAT code comes out to about 220 bytes plus the bytecode payload—a drastic reduction in size over the statically linked variant. To create and run the dynamic binary, you can invoke Javy as:

This path means the provider library can be shared across multiple Shopify Functions while heavily precompiled and optimized ahead of time. We’re also able to deploy bug fixes and engine optimizations without forcing developers to redeploy their function—as long as the interface and bytecode semantics stay stable.

As already mentioned, we rely on stdin and stdout via WASI. But for scripting in JavaScript outside the pure spec, there’s more flexibility needed—that remains an area we encapsulate under the phrase “Javy runtime.”

Building a Runtime without Reinventing One

QuickJS’s feature set is thoroughly entrenched in ECMAScript: strings, arrays, objects, plus their full set of methods, along with ES modules, JSON parsing, RegExps, ArrayBuffers and their views. Everything defined in the ECMAScript language spec is built in. Yet typical developers are used to much more: reading files from disk, making network requests, or using APIs closer to what the web platform offers. There were no vendor-neutral ways to cover those gaps, and runtimes got creative each their own way.

Our intent is to unlock the primary use cases for Javy without inventing another set of custom APIs, so we’ve kept the available APIs extremely close to the platform. In the future we plan to align with the WinterCG—for which we’re a founding member—which is working on making runtimes like Deno, Cloudflare Workers and others more interoperable. A longer-term goal is to have Javy be a WinterCG-compliant runtime.

Reading and Writing in the Javy/WASIm World

The immediate question remains: how do developers read from stdin or write to stdout using JavaScript? Neither JavaScript’s built-ins nor WinterCG has a standardized answer to that problem yet. For this purpose we introduced a global Javy—taking inspiration from Deno—which will hold all of our Javy-specific, non-standard additions. Right now, these are two low-level functions that closely resemble POSIX syscalls:

These functions are simple and flexible but not convenient. That’s why we published the javy library on npm with convenient wrappers like readFileSync and writeFileSync.

Text Encoding Coverage

Since those base I/O APIs work on UInt8Arrays and thus binary data, converting between text strings and byte arrays is part of the daily flow. Shopify Functions will read data like GraphQL responses as input, which are coming in as JSON strings. The body of the problem got solved on the Web by TextEncoder and TextDecoder from the WHATWG. Those APIs are not part of the ECMAScript spec, so QuickJS doesn’t include them out of the box. There are high-fidelity JavaScript polyfills available through npm, but for performance we chose to implement them in Rust. To ensure interoperability and behavioral parity with other runtimes, we now also run relevant Web Platform Test cases as part of the Javy test suite. Now we only support UTF-8 encoding/decoding—even though the spec technically details more encodings, but we weighed that against the lack of practical need for it today.

Event loop availability

Similarly, the promise-based accessibility that developers know—async/await, Promise, timers such as setTimeout—is syntactically okay, but those callbacks never fire even when code is written with them. Event-loop support is a goal, but there are open questions around how that event loop would integrate with the host, and how setTimeout() is supposed to behave in a WASI-limited environment.

That completes the foundation: a JavaScript runtime that behaves much like others, generates WASI-compatible Wasm modules, and supports splitting engine and dynamic user code. This is really what lets us build out Shopify Functions features on top.

Developers Have Other Needs

Near the top of the list of improvements after trial runs with a few small Shopify Functions was the handling of repetitive boilerplate. Every function does the same: read bytes from stdin till the stream is exhausted, turn bytes into a string and parse as JSON, run the business logic, then turn the result back into JSON and pipe it to stdout. It’s not a lot of code, but developers shouldn’t have to rewrite it.

The next flashpoint was about role. Javy is conceived as a JavaScript runtime: run the script top-to-bottom and the developer’s global code can call what it needs. Shopify Functions, like other edge compute runtimes (say, AWS Lambda or Cloudflare Workers), expect user code to export an entry point that the system invokes per activity. To reconcile that, we’re providing a layer between Javy and user code—a small package @shopify/shopify_function—that takes care of the boilerplate while letting the developer provide a single exported function:

The key trick: rather than treating the developer’s function code as the point of entry for Javy, we make this library the one Javy calls, which performs the plumbing and then delegates to the developer’s function. Instead of building module resolution and multi-file support into Javy, we use ESBuild as part of a build step to inline all dependencies into one large JavaScript file. Within that process, an alias maps user-function to the developer’s code. Developers can also take advantage of other npm libraries they’re used to (like i18n) in that union. Templates handle the build step and can be scaffolded and run through the Shopify CLI.

Typed inputs, generated for GraphQL

Shopify’s Function extension points consume many inputs from our GraphQL API. We use @graphql-codegen/cli in the template to generate TypeScript type definitions from the GraphQL schema and the developer’s query. That way even though developers may write either vanilla JavaScript or TypeScript, the generated types improve autocomplete and error-catching, which makes dealing with query responses much more manageable.

An End-To-End Example

To test your own JavaScript-powered Shopify Function locally, use the Shopify CLI with a JavaScript function extension to opt into the developer preview:

The scaffolded extension’s source lives at extensions/<extension name>/src/main.ts (or .js for JavaScript), with initial code expecting nothing. Run build with npm run build from the extension’s directory to output the module at dist/function.wasm. That module can be run on multiple paths—with Wasmtime locally, via your function-runner tool, or through the CLI. Shopify also documents the feature in detail on Shopify.dev.

Performance for a Future in Production

Performance restrictions are real for Shopify Functions: they need to complete in 5ms or less—a key reason Rust got the first Functions pass. First benchmarks here show the same business logic running as JavaScript-in-Wasm via Javy weaves in about 3x slower than its Rust-only output on identical hardware. Yet for most realistic input sizes, both end up within the 5ms budget—a 3x slower module can stay viable—which is the hard qualification for whether JS has a seat at the table for Function extension points.

SpiderMonkey on Wasm

Performance budgets tend to get saturated as developers push the limits of a platform. To keep JavaScript running fast on Shopify Functions, Shopify has partnered with Igalia to port Mozilla's SpiderMonkey engine to WebAssembly. Igalia is an open-source consultancy that contributes heavily to projects such as Google's Blink and Apple's WebKit. This port is expected to yield substantial performance gains over the current QuickJS-based runtime.

Beyond that immediate improvement, Shopify is also exploring longer-term work with the WebAssembly WG to extend the standard so that just-in-time compilation becomes possible. This is still in early research, but the ability to ship JITs inside Wasm could benefit many dynamic languages, not just JavaScript.

What's Available Now

Deployment to production is not yet open for JavaScript functions. Shopify is still validating the runtime and wants to ensure it covers the capabilities developers need. A local developer preview is available, and Shopify is asking for feedback from developers who try it. Bugs and problems can be reported by filing an issue on the shopify-function-javascript repository.

Production deployment is expected to open in a beta phase over the coming months. Shopify's goal is to make Functions viable for a broader range of developers and use cases, and the team is looking forward to seeing what the community builds with it.