Read OSS

JSG: The C++↔V8 Binding Layer That Powers the Workers API

Advanced

Prerequisites

  • Articles 1-3 (understanding IoContext to know what JSG methods operate within)
  • Strong C++ knowledge: templates, macros, SFINAE/concepts
  • V8 embedder API basics: Isolate, Context, Local/Global handles, FunctionCallbackInfo
  • Understanding of garbage collection and weak references

JSG: The C++↔V8 Binding Layer That Powers the Workers API

Every Workers API method — fetch(), crypto.subtle.digest(), env.MY_KV.get() — is implemented in C++. Something has to bridge the gap between V8's JavaScript world and workerd's C++ world. That something is JSG (JavaScript Glue), a macro-based binding system that automatically converts types, manages object lifetimes, and dispatches method calls. It's not a code generator, not an IDL compiler — it's pure C++ macros and template metaprogramming. This article explains why that decision was made, how it works, and how to use it.

Why Macros Instead of Code Generation?

Most JavaScript runtimes use some form of IDL (Interface Definition Language) to describe their API surface. WebIDL files are parsed by a code generator that emits C++ glue code. Node.js uses a mix of N-API and manual V8 calls. Deno uses Rust proc macros.

workerd took a different path: JSG uses C++ preprocessor macros and heavy template metaprogramming. The binding declarations live inside the class they describe, not in a separate IDL file. This was a deliberate design choice with clear tradeoffs:

Advantages: Bindings are co-located with implementation, making it impossible for them to drift out of sync. Type checking happens at compile time through C++ templates. No separate build step or generated files to manage. And the full power of C++ conditionals is available inside the binding block — which is how compatibility flag branching works.

Disadvantages: The macros are complex and error messages can be cryptic. Understanding JSG requires understanding both V8's embedder API and advanced C++ template techniques. And the single-registration-point design means all API types must be listed in one place.

JSG_RESOURCE_TYPE: Declaring JavaScript-Visible Classes

The core macro is JSG_RESOURCE_TYPE. Here's the macro definition:

src/workerd/jsg/jsg.h#L56-L81

And here's a real-world example — the Navigator class:

src/workerd/api/global-scope.h#L82-L127

class Navigator: public jsg::Object {
 public:
  kj::StringPtr getUserAgent() { return "Cloudflare-Workers"_kj; }
  kj::uint getHardwareConcurrency() { return 1; }

  JSG_RESOURCE_TYPE(Navigator, CompatibilityFlags::Reader reader) {
    JSG_METHOD(sendBeacon);
    JSG_READONLY_INSTANCE_PROPERTY(userAgent, getUserAgent);
    JSG_READONLY_INSTANCE_PROPERTY(hardwareConcurrency, getHardwareConcurrency);

    if (reader.getEnableNavigatorLanguage()) {
      JSG_READONLY_INSTANCE_PROPERTY(language, getLanguage);
    }
  }
};

Several things to notice:

  1. The class extends jsg::Object — the base class that provides V8 wrapper management via Wrappable.
  2. The macro accepts an optional configuration parameter — here CompatibilityFlags::Reader reader. This is how conditional API exposure works: properties are only registered if the compatibility flag is enabled.
  3. Registration macros are imperativeJSG_METHOD, JSG_READONLY_INSTANCE_PROPERTY, etc. are executed inside what is effectively a registerMembers() function body.

The family of registration macros:

Macro Purpose
JSG_METHOD(name) Instance method callable from JS
JSG_STATIC_METHOD(name) Static method on constructor
JSG_READONLY_INSTANCE_PROPERTY(name, getter) Read-only property
JSG_INSTANCE_PROPERTY(name, getter, setter) Read-write property
JSG_LAZY_READONLY_INSTANCE_PROPERTY(name, getter) Lazily initialized read-only property
JSG_NESTED_TYPE(Type) Register a nested type on the constructor
JSG_ITERABLE(method) Make type iterable via Symbol.iterator
JSG_METHOD_NAMED(jsName, cppMethod) Method with different JS/C++ names

Tip: When adding a new API to the global scope, treat it as a breaking change — even a new property can break Workers that do feature-sniffing or monkey-patching. The comment in WorkerGlobalScope's JSG_RESOURCE_TYPE block says this explicitly: "Every new export here must be treated as a potentially breaking change." Always gate new globals behind a compat flag.

Automatic Type Marshaling

The real power of JSG is automatic type conversion. When you declare a C++ method like:

kj::Maybe<kj::String> getItem(kj::String key);

JSG automatically generates code to: extract the key argument from V8's FunctionCallbackInfo, convert it from a V8 string to kj::String, call the C++ method, convert the kj::Maybe<kj::String> result to either null or a V8 string, and return it to JavaScript.

The complete mapping tables from JSG's README:

src/workerd/jsg/README.md

Key mappings include:

C++ Type JavaScript Type Notes
kj::String / kj::StringPtr string Owned vs. view semantics
kj::Maybe<T> T or null Both null and undefinedkj::none
jsg::Optional<T> T or undefined null throws (unlike Maybe)
kj::OneOf<T, U> Union type Validated at compile time
jsg::Ref<T> Resource wrapper Strong ref to C++ object
jsg::Promise<T> Promise Full .then()/.catch_()
jsg::Function<R(A...)> Function Bidirectional callable
kj::Array<kj::byte> ArrayBuffer Zero-copy shared backing store
jsg::BufferSource ArrayBuffer/TypedArray Type-preserving with detach support

There's also a "magic parameter" system: if a method takes jsg::Lock& as its first parameter, it receives the JSG lock object (giving access to the V8 isolate). If it takes const jsg::TypeHandler<T>&, it gets a callback for manual type conversion. These parameters are invisible to JavaScript callers.

flowchart LR
    JS["JavaScript call:<br/>navigator.getUserAgent()"] --> V8["V8 FunctionCallbackInfo"]
    V8 --> Extract["Extract args from CallbackInfo"]
    Extract --> Convert["Convert V8 → C++ types"]
    Convert --> Call["Call C++ method"]
    Call --> ConvertBack["Convert C++ → V8 types"]
    ConvertBack --> Return["Set return value"]

Wrappable and V8 Wrapper Object Management

Every JSG resource type inherits from Wrappable, which manages the bidirectional link between a C++ object and its V8 JavaScript wrapper:

src/workerd/jsg/wrappable.h#L44-L80

The design has several key properties:

  1. Lazy wrapper creation: A C++ Wrappable object doesn't get a JavaScript wrapper until it's first passed into JS. This means objects that stay purely in C++ have no V8 overhead.

  2. Ref-counted C++ side: Wrappable inherits from kj::Refcounted. The V8 wrapper holds a reference. When V8 garbage-collects the wrapper, this reference is dropped.

  3. Dual reference counting: There's a second reference count on the wrapper itself. While any C++ code holds a strong reference to the Wrappable, the V8 wrapper is marked as a strong root, preventing GC. When all C++ strong references are released, the wrapper becomes weak — V8 can collect it.

The ContextPointerSlot enum defines embedder data slots in V8 contexts:

enum class ContextPointerSlot : int {
  RESERVED = 0,
  GLOBAL_WRAPPER = 1,
  MODULE_REGISTRY = 2,
  EXTENDED_CONTEXT_WRAPPER = 3,
  VIRTUAL_FILE_SYSTEM = 4,
};

These slots are how JSG finds the C++ objects associated with a V8 context — the global scope wrapper, the module registry, and the virtual file system are all stored here.

A subtle optimization: the codebase defines kj::MaybeTraits for v8::TracedReference<T>, enabling a niche-value optimization. Instead of kj::Maybe adding a bool + padding (8 extra bytes), it uses TracedReference's built-in IsEmpty() state as the "none" representation. At the scale of hundreds of wrapped objects per isolate, this adds up.

V8System, IsolateBase, and Isolate Type Registration

As we saw in Part 2, V8System handles process-wide V8 initialization. The per-isolate setup happens through IsolateBase:

src/workerd/jsg/setup.h#L86-L120

But the most critical piece is the type registration — the single point where all API types are declared. This happens via JSG_DECLARE_ISOLATE_TYPE in workerd-api.c++:

src/workerd/server/workerd-api.c++#L84-L153

JSG_DECLARE_ISOLATE_TYPE(JsgWorkerdIsolate,
    EW_GLOBAL_SCOPE_ISOLATE_TYPES,
    EW_ACTOR_ISOLATE_TYPES,
    EW_ACTOR_STATE_ISOLATE_TYPES,
    EW_CACHE_ISOLATE_TYPES,
    EW_CRYPTO_ISOLATE_TYPES,
    EW_ENCODING_ISOLATE_TYPES,
    // ... 40+ more type groups
    jsg::TypeWrapperExtension<PromiseWrapper>,
    jsg::InjectConfiguration<CompatibilityFlags::Reader>,
    Worker::Api::ErrorInterface);

Each EW_*_ISOLATE_TYPES macro is defined in the corresponding API header file and expands to a comma-separated list of C++ types. For example, EW_GLOBAL_SCOPE_ISOLATE_TYPES (defined in api/global-scope.h) lists ServiceWorkerGlobalScope, Navigator, Cloudflare, WorkerGlobalScope, and every other type needed by the global scope.

flowchart TD
    JDIT["JSG_DECLARE_ISOLATE_TYPE<br/>(workerd-api.c++)"] --> GS["EW_GLOBAL_SCOPE_ISOLATE_TYPES<br/>(global-scope.h)"]
    JDIT --> AC["EW_ACTOR_ISOLATE_TYPES<br/>(actor.h)"]
    JDIT --> CR["EW_CRYPTO_ISOLATE_TYPES<br/>(crypto/impl.h)"]
    JDIT --> ST["EW_STREAMS_ISOLATE_TYPES<br/>(streams.h)"]
    JDIT --> More["... 40+ more groups"]

    GS --> Types1["ServiceWorkerGlobalScope<br/>Navigator, Cloudflare, ..."]
    AC --> Types2["DurableObject<br/>DurableObjectId, ..."]
    CR --> Types3["CryptoKey<br/>SubtleCrypto, ..."]

Two special entries at the end deserve attention: jsg::InjectConfiguration<CompatibilityFlags::Reader> makes compatibility flags available to every JSG_RESOURCE_TYPE block that declares a configuration parameter, and jsg::TypeWrapperExtension<PromiseWrapper> integrates KJ promises with JavaScript promises.

Tip: If you add a new API type and forget to add it to the corresponding EW_*_ISOLATE_TYPES macro, the compiler won't catch it — but your type won't exist in JavaScript. Always test by actually calling your new API from JS.

The Call Path: V8 FunctionCallback to C++ Method

When JavaScript calls navigator.getUserAgent(), here's what actually happens:

sequenceDiagram
    participant JS as JavaScript
    participant V8 as V8 Engine
    participant CB as JSG FunctionCallback
    participant TM as Type Marshaling
    participant CPP as C++ Navigator::getUserAgent()

    JS->>V8: navigator.getUserAgent()
    V8->>CB: FunctionCallbackInfo with args
    CB->>CB: Extract 'this' from args.HolderV2()
    CB->>CB: Downcast to Navigator* via Wrappable
    CB->>TM: Extract & convert arguments (none here)
    CB->>CPP: navigator->getUserAgent()
    CPP-->>CB: kj::StringPtr result
    CB->>TM: Convert kj::StringPtr → v8::String
    TM-->>CB: v8::Local<v8::String>
    CB->>V8: args.GetReturnValue().Set(result)
    V8-->>JS: "Cloudflare-Workers"

The JSG template machinery generates a V8 FunctionCallback for each registered method. This callback:

  1. Extracts the C++ object pointer from the V8 wrapper via args.HolderV2() and the Wrappable base class
  2. Uses template metaprogramming to determine the expected C++ parameter types
  3. Extracts each JavaScript argument from FunctionCallbackInfo and converts it using the type-appropriate converter
  4. Calls the C++ method with the converted arguments
  5. Converts the return value back to V8 and sets it on the return value slot
  6. If the C++ method throws a kj::Exception, catches it and converts it to a JavaScript exception

Exception propagation is particularly carefully handled — JavaScript exceptions from callbacks become JsExceptionThrown in C++, and C++ exceptions become JavaScript Error objects. The jsg::Lock plays a role here too, ensuring the V8 isolate lock is held during all conversions.

With JSG understood, we now have the complete picture from JavaScript API call down to C++ implementation. In the final article, we'll examine two production-critical subsystems that build on everything we've covered: Durable Objects (with their gate-based consistency model) and the compatibility date system that keeps it all backwards-compatible.