# dop251/goja: a pure Go ECMAScript 5.1 engine for embedding JavaScript in Go programs

> goja runs JavaScript inside a Go process with no cgo, which makes it a fit for scripting layers and configuration languages rather than for heavy JavaScript workloads. The README is explicit about where it stops being the right tool.

**dop251/goja** — ECMAScript/JavaScript engine in pure Go

- Repository: https://github.com/dop251/goja
- Stars: 7,120 · Forks: 478
- Language: Go
- License: MIT
- Published: 2026-09-22 · Updated: 2026-09-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/dop251-goja

## What goja solves, and who ends up using it

Go programs that need user-supplied logic have three options: compile a plugin, define a configuration format, or embed a scripting language. goja takes the third path with an ECMAScript 5.1 implementation written entirely in Go. The README frames the motivation indirectly, in its answer to why anyone would pick it over a V8 wrapper: if you need a scripting language that drives an engine written in Go, and you make frequent calls between Go and JavaScript passing complex data structures, the cgo overhead of a V8 binding can outweigh the faster engine. That is the audience. Not people running JavaScript as the main workload, but people writing Go who want a small, controlled scripting surface on top of it.

The second audience is build and deployment. Because goja is pure Go, the README states there are no cgo dependencies, it is easy to build, and it should run on any platform supported by Go. For a team shipping to several architectures, that removes an entire class of cross-compilation problems that a V8 binding introduces.

The third is research. The README notes that goja gives much better control over the execution environment, which is useful when you want to inspect or constrain what a script does. That control is real but partial: the host application owns concurrency, timers and I/O, as the setTimeout answer makes clear.

## How the engine is put together and how values cross the Go boundary

The repository layout shows a conventional tree-walking design split across compiler.go, compiler_expr.go and compiler_stmt.go, with an ast/ package for parsing and a file/ package alongside it. Builtins are separated into one file per global object: builtin_array.go, builtin_json.go, builtin_regexp.go, builtin_promise.go, builtin_proxy.go, builtin_map.go, builtin_typedarrays.go and so on. That layout matters when you are judging coverage. The presence of builtin_proxy.go and builtin_promise.go tells you ES6 features are present in some form, but the README is clear that most of ES6 is still work in progress and points at a milestone for the closed items.

Data flow across the boundary runs through two method pairs. Runtime.ToValue() converts any Go value into a JavaScript value, and Value.Export() converts a JavaScript value back into its default Go representation. When you need a specific Go type rather than the default, Runtime.ExportTo() does the conversion into a variable you supply. The README documents one behaviour worth knowing: within a single export operation the same JavaScript object is represented by the same Go value, whether that is the same map, the same slice or a pointer to the same struct. That identity preservation covers circular objects, which is what makes exporting a self-referential structure possible at all.

Calling in the other direction has two documented routes. AssertFunction() checks that a value is callable and returns a function you call with an explicit this argument, which is how the README's example passes goja.Undefined() as the receiver. ExportTo() instead converts the function into a plain Go func value. The trade-off is stated in the README: with the ExportTo route, the this value inside the function will be undefined. If your script relies on this, the AssertFunction route is the one that keeps it.

## Installing goja and running a first script

There is no binary to install and no CLI. goja is a library, so the install step is adding the module to a Go project. The go.mod in the repository declares module github.com/dop251/goja and requires Go 1.25.0, and the README states the minimum required Go version is 1.25. Add it with go get, then import the package path.

```bash
go get github.com/dop251/goja
```

With the module in place, the smallest useful program creates a runtime, evaluates a string, and inspects the result. The README gives exactly this example.

```go
vm := goja.New()
v, err := vm.RunString("2 + 2")
if err != nil {
    panic(err)
}
if num := v.Export().(int64); num != 4 {
    panic(num)
}
```

Run it and nothing is printed, because the assertion succeeds. The value returned by RunString is a goja.Value, and Export() gives you the Go representation, which for a JavaScript number in this example is an int64.

The next step is usually calling a JavaScript function from Go. The README shows the AssertFunction approach, which keeps the this value under your control.

```go
const SCRIPT = `
function sum(a, b) {
    return +a + b;
}
`

vm := goja.New()
_, err := vm.RunString(SCRIPT)
if err != nil {
    panic(err)
}
sum, ok := goja.AssertFunction(vm.Get("sum"))
if !ok {
    panic("Not a function")
}

res, err := sum(goja.Undefined(), vm.ToValue(40), vm.ToValue(2))
if err != nil {
    panic(err)
}
fmt.Println(res)
```

The README states the output is 42. Arguments are converted with ToValue() rather than passed as Go values directly, and the first argument to the call is the receiver. If you would rather work with a Go function signature, ExportTo() into a func(int, int) int works too, with the this caveat noted above.

## The failures you will actually hit: UTF-16, dates, goroutines and timers

The README keeps a section called Known incompatibilities and caveats, and it is the most useful part of the document. JSON.parse() delegates to the standard Go library, which operates in UTF-8. A lone surrogate such as \uD800 therefore does not survive: the README's example shows charCodeAt(0).toString(16) returning fffd instead of d800. If you parse JSON containing broken surrogate pairs, you get replacement characters rather than an error.

Date conversion has a different problem. Converting a calendar date to an epoch timestamp goes through the standard Go library, which uses int rather than the float the ECMAScript specification requires. Arguments that overflow int, or an internal integer overflow, produce a wrong result. The README's example is Date.UTC(1970, 0, 1, 80063993375, 29, 1, -288230376151711740), which returns 29256 instead of 29312. These are not edge cases you will meet in ordinary application scripts, but they are exactly the sort of input a fuzzer or a test262 run will produce.

Concurrency is the constraint with the widest reach. The README states plainly that goja is not goroutine-safe: an instance of goja.Runtime can only be used by a single goroutine at a time. You can create as many Runtime instances as you like, but object values cannot be passed between runtimes. That rules out the pattern of a shared VM behind a mutex serving many requests, at least not without serialising every call yourself. Per-request or per-worker runtimes are the shape the API supports.

Timers are a deliberate omission. setTimeout() and setInterval() are not part of the ECMAScript standard, and goja does not provide them. The README's position is that the hosting application controls the environment for concurrent execution, meaning an event loop, and supplies the functionality to script code. A separate project, goja_nodejs, provides some NodeJS functionality including an event loop. Note that go.mod lists goja_nodejs as a dependency, so the relationship is closer than a casual mention, but the event loop still lives outside the core package.

## goja against otto and against V8 bindings

The README names otto as the project's inspiration, and the comparison is the most concrete performance statement in the document: goja is described as 6-7 times faster than otto on average, with benchmarks linked from a GitHub issue. That claim is about relative speed between two pure Go engines, and it does not make goja competitive with a native engine. The same FAQ says directly that goja is not a replacement for V8 or SpiderMonkey or any other general-purpose JavaScript engine.

Against a V8 binding, the difference is not speed but where the boundary cost falls. If most of the work happens inside JavaScript, the README says you are definitely better off with V8. If the JavaScript is a thin layer over Go code with frequent calls and complex data structures crossing the boundary, cgo overhead can erase V8's advantage. The pure Go build is the tiebreaker: no cgo means no C toolchain, no per-platform binary artifacts, and cross-compilation that behaves like any other Go build.

The honest summary is that goja competes on integration cost, not execution speed. Anyone choosing it for throughput has picked the wrong axis.

## Maintenance, ES6 progress and what the MIT licence leaves you to decide

The repository is not archived, and the last push was on 2026-09-17, five days before this writing. That is a current codebase, not an abandoned one. There are no retrieved releases, so version pinning happens against module pseudo-versions rather than tags, which is worth knowing before you write a dependency policy around it.

Upgrade cost is dominated by ES6 coverage rather than API churn. The README states there should be no breaking changes in the API, though it may be extended, and that some AnnexB functionality is missing. New features land in dependency order and are developed on separate feature branches merged into master when appropriate. The README notes that each commit on those branches compiles and passes all enabled tc39 tests, but that the tc39 test version in use is quite old, so newer features may be less well tested than the ES5.1 core. That is the real upgrade risk: not a renamed method, but a feature that was tested against an older conformance suite.

The README also asks that bug fixes not be submitted without discussion first, since the code may be changing in the meantime. If you depend on a fix, expect to coordinate rather than send a patch.

The licence is MIT, which is permissive and compatible with commercial use. The repository carries the LICENSE file at the top level and the description lists MIT as the identifier. Whether that satisfies your organisation's dependency review is a question for your own process, not something the repository decides for you.

## Conclusion

Adopt goja when the JavaScript is a scripting layer over Go code that makes frequent calls across the boundary with complex data structures, and when you want a build with no cgo dependencies. Do not adopt it when most of the work happens inside JavaScript, or when you need setTimeout and setInterval from the core package, or when you need a Runtime shared across goroutines. Before committing, check the milestone for the ES6 features your scripts use, confirm the tc39 test commit referenced by .tc39_test262_checkout.sh, and read the JSON and Date caveats in the README against the inputs you actually expect.

## FAQ

### Is goja goroutine-safe?

No. The README states that an instance of goja.Runtime can only be used by a single goroutine at a time. You can create as many Runtime instances as you like, but object values cannot be passed between runtimes.

### Does goja provide setTimeout and setInterval?

No. The README explains that the two functions are not part of the ECMAScript standard and that the hosting application is expected to control the environment for concurrent execution, such as an event loop. A separate project, goja_nodejs, provides some NodeJS functionality and includes an event loop.

### How fast is goja compared to other JavaScript engines?

The README says goja is 6-7 times faster than otto on average but is not a replacement for V8 or SpiderMonkey or any other general-purpose JavaScript engine. Benchmarks are linked from a GitHub issue.

### Can goja run TypeScript?

The README states goja is capable of running Babel, the TypeScript compiler, and pretty much anything written in ES5. Running the TypeScript compiler is not the same as executing TypeScript directly; the compilation output is what runs.

### How does JavaScript actually work?

goja is not a general-purpose JavaScript engine and does not document the language's internals. Its README describes what it implements: ECMAScript 5.1 with emphasis on standard compliance and performance, most of ES6 still in progress, and the specific JSON and Date caveats that follow from using the standard Go library.

## Sources

- [dop251/goja on GitHub](https://github.com/dop251/goja)
- [Issues](https://github.com/dop251/goja/issues)
- [License: MIT](https://github.com/dop251/goja/blob/master/LICENSE)
- [README](https://github.com/dop251/goja/blob/master/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/dop251-goja
