Implementing Promise-based and callback-style async operations
Asynchronous operations in go-go-goja bridge Go's goroutine model with JavaScript's Promise and callback patterns. The key constraint is that all JavaScript VM interactions must occur through the runtime owner. Background goroutines may do blocking Go work, but they must schedule Promise settlement, JavaScript callback invocation, and goja.Value access back onto the owner.
A goja runtime is single-threaded from JavaScript's point of view. Any operation that touches JavaScript values, calls JavaScript functions, or resolves Promises must happen on the runtime owner.
For native modules loaded inside an engine.Runtime, use pkg/runtimebridge.RuntimeServices:
runtimeServices, ok := runtimebridge.Lookup(vm)
if !ok || runtimeServices.Owner == nil {
panic(vm.NewGoError(fmt.Errorf("module requires runtime services")))
}
RuntimeServices gives module code:
Owner: serialized access to the VM;Loop: the underlying event loop when low-level integration is unavoidable;Lifetime(): the runtime-owned lifetime context;The runtime API deliberately separates several context meanings:
| Context | Purpose | Typical API |
|---|---|---|
| Startup context | Runtime construction and initializers | engine.WithStartupContext(ctx) |
| Lifetime context | Runtime-owned resources after construction | engine.WithLifetimeContext(ctx), RuntimeServices.Lifetime() |
| Current owner-entry context | The context for the JavaScript/native callback currently running on owner | runtimebridge.CurrentOwnerContext(vm) |
| Custom operation context | HTTP request, Discord event, hardware event, or other external operation | CallWithCustomContext, PostWithCustomContext |
Create runtimes with explicit startup and lifetime contexts:
rt, err := factory.NewRuntime(
engine.WithStartupContext(ctx),
engine.WithLifetimeContext(ctx),
)
Use separate contexts when construction and runtime lifetime are different:
rt, err := factory.NewRuntime(
engine.WithStartupContext(startupCtx),
engine.WithLifetimeContext(lifetimeCtx),
)
| Situation | Use |
|---|---|
| JS-facing native function calls another JS callback synchronously | CallWithCurrentContext(vm, op, fn) |
| JS-facing native function schedules a follow-up on owner | PostWithCurrentContext(vm, op, fn) |
| Runtime-owned background work settles a Promise | PostWithLifetimeContext(op, fn) or PostWithCustomContext(callCtx, op, fn) |
| External request/event has its own context | CallWithCustomContext(ctx, op, fn) / PostWithCustomContext(ctx, op, fn) |
| Need current callback context only | runtimebridge.CurrentOwnerContext(vm) |
| Need runtime lifetime only | runtimebridge.LifetimeContext(vm) or services.Lifetime() |
CallWithCustomContext and PostWithCustomContext link custom contexts to runtime lifetime cancellation. CallWithCustomContext cancels its linked context after the owner call returns. PostWithCustomContext keeps the linked context alive until the posted callback has executed, then unregisters the lifetime cancellation hook.
func installSleep(vm *goja.Runtime, exports *goja.Object) {
runtimeServices, ok := runtimebridge.Lookup(vm)
if !ok || runtimeServices.Owner == nil {
panic(vm.NewGoError(fmt.Errorf("timer module requires runtime services")))
}
_ = exports.Set("sleep", func(ms int64) goja.Value {
promise, resolve, reject := vm.NewPromise()
callCtx := runtimebridge.CurrentOwnerContext(vm)
runtimeCtx := runtimeServices.Lifetime()
go func() {
if ms < 0 {
_ = runtimeServices.PostWithCustomContext(callCtx, "timer.sleep.reject", func(context.Context, *goja.Runtime) {
_ = reject(vm.ToValue("timer.sleep: duration must be >= 0"))
})
return
}
timer := time.NewTimer(time.Duration(ms) * time.Millisecond)
defer timer.Stop()
select {
case <-callCtx.Done():
return
case <-runtimeCtx.Done():
return
case <-timer.C:
}
_ = runtimeServices.PostWithCustomContext(callCtx, "timer.sleep.resolve", func(context.Context, *goja.Runtime) {
_ = resolve(goja.Undefined())
})
}()
return vm.ToValue(promise)
})
}
JavaScript usage:
const timer = require("timer");
async function example() {
console.log("Starting...");
await timer.sleep(1000);
console.log("Done after 1 second!");
}
example();
A fresh runtime still does not expose global setTimeout/setInterval; the supported primitive is require("timer").sleep(ms).
For retained callbacks that may be evaluated while a JS-facing native function is already on the owner, use current-context helpers. This avoids scheduling a nested owner call that waits for itself.
tile.BindText(func() string {
ret, err := runtimeServices.CallWithCurrentContext(vm, "ui.tile.text", func(context.Context, *goja.Runtime) (any, error) {
value, err := jsTextCallback(goja.Undefined())
if err != nil {
return nil, err
}
return value.String(), nil
})
if err != nil {
panic(vm.NewGoError(err))
}
return ret.(string)
})
For hardware or network events that arrive from outside the current JS call stack, use a custom event/request context when available, or the lifetime context for runtime-owned events:
_ = runtimeServices.PostWithLifetimeContext("device.button", func(context.Context, *goja.Runtime) {
_, err := jsButtonCallback(goja.Undefined())
if err != nil {
panic(vm.NewGoError(err))
}
})
Do not execute blocking synchronous flows on the owner thread when those flows wait on background goroutines that themselves need to schedule owner-thread callbacks. That creates a circular wait.
In practice:
RuntimeServices helper methods;CallWithCurrentContext for nested JS-facing callbacks;PostWithCustomContext / PostWithLifetimeContext for background settlement.engine.Runtime.Close(ctx) cancels the runtime lifetime context, waits briefly for active owner calls to finish, interrupts active JavaScript if necessary, runs registered closers while runtime services are still available, then removes runtimebridge services and stops the owner/event loop.
Native modules with runtime-owned resources should register closers via RuntimeModuleContext.AddCloser or the xgoja runtime handle closer registry. Closers should expect the lifetime context to already be canceled, but they may still use runtime services for final owner-thread cleanup.
For long-lived Go resources that push events into JavaScript, prefer the connected-emitter pattern in pkg/jsevents:
EventEmitter with require("events").fswatch.watch(path, emitter).goja.Runtime, goja.Value, or JS callbacks directly; they call EmitterRef.Emit(...) instead.close() cancels the Go-side resource without removing JavaScript listeners from the emitter.Example JavaScript shape:
const EventEmitter = require("events");
const watcher = new EventEmitter();
const conn = fswatch.watch("/tmp/demo", watcher, {
recursive: true,
debounceMs: 100,
include: ["**/*.js"],
exclude: ["**/node_modules/**"]
});
watcher.on("event", (ev) => console.log(ev.relativeName, ev.op, ev.debounced, ev.count));
watcher.on("error", (err) => console.error(err.message));
conn.close();
The embedding Go application must explicitly install the manager and helper:
factory, err := engine.NewBuilder().
WithRuntimeInitializers(
jsevents.Install(),
jsevents.FSWatchHelper(jsevents.FSWatchOptions{
Root: "/tmp/demo",
AllowRecursive: true,
MaxDebounce: time.Second,
}),
).
Build()
See goja-repl help connected-eventemitters-developer-guide for the full developer guide, including owner-thread safety, typed payload structs, fswatch, and Watermill.