QJS is a CGO-free, modern JavaScript runtime for Go that embeds the QuickJS engine in a WebAssembly module and runs it with Wazero, providing Go applications with a sandboxed ES2023 environment with async/await and tight Go–JS interoperability.
QJS targets Go developers who want to run modern JavaScript inside Go processes without linking to native C libraries. Instead of binding QuickJS directly via CGO, it compiles QuickJS-NG to WebAssembly and executes it under Wazero, providing:
- Full ES2023 support (modules, async/await, BigInt, etc.).
- A fully sandboxed, memory-safe execution model.
- No CGO toolchain or C runtime dependency.
The runtime is compatible with Go 1.22+ and is distributed as a regular Go module:
go get github.com/fastschema/qjs
and then:
import "github.com/fastschema/qjs"
QJS exposes a Runtime and Context API that lets Go code evaluate JavaScript, bind functions, and exchange data structures. A minimal example creates a runtime, evaluates a script, and reads structured results back into Go:
rt, err := qjs.New()
if err != nil {
log.Fatal(err)
}
defer rt.Close()
ctx := rt.Context()
result, err := ctx.Eval("test.js", qjs.Code(`
const person = {
name: "Alice",
age: 30,
city: "New York"
};
const info = Object.keys(person).map(key =>
key + ": " + person[key]
).join(", ");
({ person: person, info: info });
`))
if err != nil {
log.Fatal("Eval error:", err)
}
defer result.Free()
log.Println(result.GetPropertyStr("info").String())
log.Println(result.GetPropertyStr("person").GetPropertyStr("name").String())
log.Println(result.GetPropertyStr("person").GetPropertyStr("age").Int32())
Go functions can be exposed to JavaScript, and JS functions can be converted back to typed Go callables. For example, binding a Go function:
ctx.SetFunc("goFunction", func(this *qjs.This) (*qjs.Value, error) {
return this.Context().NewString("Hello from Go!"), nil
})
result, err := ctx.Eval("test.js", qjs.Code(`
const message = goFunction();
message;
`))
if err != nil {
log.Fatal("Eval error:", err)
}
defer result.Free()
log.Println(result.String()) // Hello from Go!
QJS also supports converting richer Go structs to JS values and back, including methods that can be invoked from JavaScript and then deserialised to typed Go values.
To avoid repeated serialisation of large or opaque Go objects, QJS introduces Proxy, a lightweight JavaScript wrapper that holds only a reference to a Go value. This is useful for contexts, DB handles, or large structs that JS should pass through without inspecting:
ctx.SetFunc("$context", func(this *qjs.This) (*qjs.Value, error) {
passContext := context.WithValue(context.Background(), "key", "value123")
val := ctx.NewProxyValue(passContext)
return val, nil
})
goFuncWithContext := func(c context.Context, num int) int {
log.Println("Context value:", c.Value("key"))
return num * 2
}
JavaScript receives the proxy and passes it back into Go, where JsValueToGo recovers the underlying value and type.QJS supports async/await by allowing Go to resolve JS promises asynchronously. A Go async function can schedule work and resolve a promise:
ctx.SetAsyncFunc("asyncFunction", func(this *qjs.This) {
go func() {
time.Sleep(100 * time.Millisecond)
result := this.Context().NewString("Async result from Go!")
this.Promise().Resolve(result)
}()
})
JS then awaits it:
async function main() {
const result = await asyncFunction();
return result;
}
({ main: main() });
The runtime can be used to implement HTTP handlers in JavaScript while keeping the server in Go. For example, routes like /about and /contact Are defined in JS, precompiled to bytecode, and executed from a pool of runtimes:
byteCode := must(ctx.Compile("script.js", qjs.Code(script), qjs.TypeModule()))
pool := qjs.NewPool(3, &qjs.Option{}, func(r *qjs.Runtime) error {
results := must(r.Context().Eval("script.js", qjs.Bytecode(byteCode), qjs.TypeModule()))
r.Context().Global().SetPropertyStr("handlers", results)
return nil
})
http.HandleFunc("/about", func(w http.ResponseWriter, r *http.Request) {
runtime := must(pool.Get())
defer pool.Put(runtime)
handlers := runtime.Context().Global().GetPropertyStr("handlers")
result := must(handlers.InvokeJS("about"))
fmt.Fprint(w, result.String())
result.Free()
})
The Pool type manages multiple runtimes for concurrent workloads, and examples show worker goroutines borrowing a runtime, executing JS, and returning it to the pool.
Because QuickJS runs inside Wazero, QJS offers filesystem and network isolation by default, with explicit configuration required to expose additional capabilities. The Option struct lets users set working directory, memory limits, stack size, execution timeouts, and GC thresholds:
type Option struct {
CWD string
MaxStackSize int
MemoryLimit int
MaxExecutionTime int
GCThreshold int
}
According to benchmarks in the repository, point QJS is very competitive with Goja and ModerncQuickJS, with lower memory usage and execution time across both factorial calculations and AreWeFastYet-style microbenchmarks. Both benchmarks were run on Linux machines with an AMD Ryzen 7 7840HS and 32GB RAM.
Computing factorial(10) 1,000,000 times
| Iteration | GOJA | ModerncQuickJS | QJS |
|---|---|---|---|
| 1 | 1.128s | 1.897s | 737.635ms |
| 2 | 1.134s | 1.936s | 742.670ms |
| 3 | 1.123s | 1.898s | 738.737ms |
| 4 | 1.120s | 1.900s | 754.692ms |
| 5 | 1.132s | 1.918s | 756.924ms |
| Average | 1.127s | 1.910s | 746.132ms |
| Total | 5.637s | 9.549s | 3.731s |
| Speed | 1.51x | 2.56x | 1.00x |
AreWeFastYet V8-V7
| Metric | GOJA | ModerncQuickJS | QJS |
|---|---|---|---|
| Richards | 345 | 189 | 434 |
| DeltaBlue | 411 | 205 | 451 |
| Crypto | 203 | 305 | 393 |
| RayTrace | 404 | 347 | 488 |
| EarleyBoyer | 779 | 531 | 852 |
| RegExp | 381 | 145 | 142 |
| Splay | 1289 | 856 | 1408 |
| NavierStokes | 324 | 436 | 588 |
| Score (version 7) | 442 | 323 | 498 |
| Duration (seconds) | 78.349s | 97.240s | 72.004s |
With this design, QJS targets Go developers who need secure plugin systems, user-provided scripting, or embedded business logic written in JavaScript, without bringing a C toolchain or CGO into their build and deployment pipeline.
