You saw v8::Local<T> all over the hello-world embedder. Every JS value came
wrapped in one. Here's the rule that explains the whole design:
v8::Object* would point at stale memory after the next GC. A handle is an entry
in a table the GC knows about — when it moves an object, it rewrites every handle to the new
address. Your handle keeps working. That's the entire point of handles.
Guide: the GC "updates all handles that refer to the object with the object's new
location." (v8.dev/docs/embed)
A Local handle (v8::Local<T>) is the everyday handle. Its lifetime is
governed by the enclosing HandleScope: when the scope is destroyed, all Locals created inside
it are released in one shot. From the guide: a HandleScope is "a container for any number of
handles… when you've finished with your handles, instead of deleting each one individually you can
simply delete their scope." And critically: "Handle scopes can only be stack-allocated, not
allocated with new."
void doWork(v8::Isolate* isolate) {
v8::HandleScope scope(isolate); // open a container (stack-allocated)
v8::Local<v8::String> a = v8::String::NewFromUtf8Literal(isolate, "hi");
v8::Local<v8::Number> b = v8::Number::New(isolate, 42);
// ...a and b are valid here; GC may run and will keep them updated...
} // scope destructed -> a and b released together. No manual cleanup.
If a function creates a Local inside its own HandleScope and returns it, the handle
dies when the scope closes — you'd return a dangling handle. The fix is EscapableHandleScope,
which lets exactly one handle "escape" into the enclosing scope:
v8::Local<v8::String> makeGreeting(v8::Isolate* isolate) {
v8::EscapableHandleScope scope(isolate);
v8::Local<v8::String> s = v8::String::NewFromUtf8Literal(isolate, "hello");
return scope.Escape(s); // promote s into the caller's scope, then close ours
}
Locals are perfect for "do something within this call." But sometimes you need to keep a JS object alive across many calls — say, a callback you stored, or a config object your host holds for its whole lifetime. That's a persistent handle. The guide: "Use a persistent handle when you need to keep a reference to an object for more than one function call, or when handle lifetimes do not correspond to C++ scopes."
Global is move-only (modern default); Persistent can be copyablev8::Global<v8::Object> saved; // a member of your host class
void remember(v8::Isolate* iso, v8::Local<v8::Object> obj) {
saved.Reset(iso, obj); // take a persistent reference; keeps obj alive
}
void forget() {
saved.Reset(); // release it -> object becomes collectible
}
Global/Persistent you never
Reset() pins its object forever — V8 can't collect it. (2) Dangling: stashing a
Local somewhere that outlives its HandleScope (a class member, a static, a captured
lambda) — it's invalid the moment the scope closes. Rule of thumb: cross a scope boundary → use
a Global.
| Need | Use |
|---|---|
| A value just for this function / call | Local<T> inside a HandleScope |
| Return a freshly-made handle to your caller | EscapableHandleScope + Escape() |
| Keep a JS object alive across calls / store it on your host | Global<T>, released with Reset() |
All quotes from v8.dev/docs/embed; Global/Persistent declared in include/v8-persistent-handle.h.
Object* pointers?HandleScope, creates a Local<String>, and returns it directly. What's wrong?Global<Object> and never call Reset(). What happens?v8::Eternal (handles that truly never die) or weak/Global-with-callback handles for
"tell me when this object is about to be collected"? Just ask — e.g. "Show me a weak Global that
runs a finalizer," or "Why is Global move-only but Persistent copyable?"
You can now reason about object lifetime across the C++/JS boundary — the thing that separates a stable embedder from one that crashes under GC pressure. The last embedding lesson is the other direction of the bridge: Lesson 6 — exposing a C++ function to JavaScript, so JS running in your engine can call back into your host code.
Reference: V8 Glossary (embedding terms) · Related: Lesson 8 (moving GC), Lesson 4