JSG: The C++↔V8 Binding Layer That Powers the Workers API
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:
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:
- The class extends
jsg::Object— the base class that provides V8 wrapper management viaWrappable. - 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. - Registration macros are imperative —
JSG_METHOD,JSG_READONLY_INSTANCE_PROPERTY, etc. are executed inside what is effectively aregisterMembers()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'sJSG_RESOURCE_TYPEblock 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:
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 undefined → kj::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:
-
Lazy wrapper creation: A C++
Wrappableobject 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. -
Ref-counted C++ side:
Wrappableinherits fromkj::Refcounted. The V8 wrapper holds a reference. When V8 garbage-collects the wrapper, this reference is dropped. -
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_TYPESmacro, 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:
- Extracts the C++ object pointer from the V8 wrapper via
args.HolderV2()and the Wrappable base class - Uses template metaprogramming to determine the expected C++ parameter types
- Extracts each JavaScript argument from
FunctionCallbackInfoand converts it using the type-appropriate converter - Calls the C++ method with the converted arguments
- Converts the return value back to V8 and sets it on the return value slot
- 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.