V8 Engine · Lesson 5 · Embedding Track

Handles & Scopes

Why this lesson: Your mission asks you to "explain Handles, HandleScopes, Local vs Persistent, and why they exist (GC safety)." Lesson 8 gave you the why — the heap moves. Now we make it concrete and operational, because handle mistakes are the #1 source of embedder crashes.

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:

You may never hold a raw pointer to a V8 (JS heap) object. The garbage collector moves objects (Scavenge copies them, Mark-Compact slides them — Lesson 8). A raw 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)

Local handles & the HandleScope

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.
Why this design is fast. Creating a Local is nearly free — it just bumps a pointer in the scope's backing store. Releasing them is one operation (drop the scope), not N. This is why V8 code creates handles liberally inside a scope rather than worrying about each one.

The problem: returning a handle past its scope

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
}

When a value must outlive any scope: Global / Persistent

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."

v8::Local<T>

  • Lifetime = enclosing HandleScope
  • Released automatically, in bulk
  • Stack-discipline; the default everywhere
  • Cheap to create; can't outlive its scope

v8::Global<T> / v8::Persistent<T>

  • Lifetime = until you Reset() it
  • Survives across function calls & scopes
  • You own it — forget to Reset and it's a leak
  • Global is move-only (modern default); Persistent can be copyable
v8::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
}
The two classic bugs. (1) Leak: a 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.
NeedUse
A value just for this function / callLocal<T> inside a HandleScope
Return a freshly-made handle to your callerEscapableHandleScope + Escape()
Keep a JS object alive across calls / store it on your hostGlobal<T>, released with Reset()

All quotes from v8.dev/docs/embed; Global/Persistent declared in include/v8-persistent-handle.h.

Check yourself feedback loop

1. Fundamentally, why does V8 force you to use handles instead of raw Object* pointers?
2. A function opens a HandleScope, creates a Local<String>, and returns it directly. What's wrong?
3. Your host class needs to hold onto a JS callback object for the program's lifetime. Which handle?
4. You created a Global<Object> and never call Reset(). What happens?
Ask me anything. Want a diagram of the handle table vs the heap? Curious about 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?"

Where this lands you

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

V8 Engine learning track · Lesson 5 · Concepts & quotes from the official Embedder's Guide.