Run today's npm code unchanged inside one static Go binary.
slowjs renders React slower than Node and faster than any other pure Go JavaScript engine, with no cgo and no Node process. It is QuickJS translated to Go and tuned for server rendering, built for tifa, a Go framework for React pages.
It is for Go programs that need to run JavaScript, such as rendering React on the server. The engine runs ES2023 with modules, async generators, proxies, BigInt and Intl in every Chromium locale.
It is an engine, not Node: there is no require, fs or process. Code from npm runs unchanged once it is bundled as for a browser, as React is.
English · 简体中文
go get github.com/liustack/slowjsGo 1.27 or later. No cgo and no C compiler: it builds with CGO_ENABLED=0 and cross-compiles like any Go code.
linux and darwin on amd64 and arm64, and windows on amd64. A build for any other target stops with an error that names these.
On linux/amd64 the engine runs JavaScript in an interpreter compiled from the C source and shipped as Go assembly, which renders tifa's shop pages 6% faster and its MUI pages 15% faster than the Go interpreter that other targets use (one VM, no PGO). -tags slowjs_nogoasm builds with the Go interpreter there too. See The goasm interpreter.
package main
import (
"errors"
"fmt"
"time"
"github.com/liustack/slowjs"
"github.com/liustack/slowjs/intl"
)
func main() {
vm, err := slowjs.NewVM()
if err != nil {
panic(err)
}
defer vm.Close()
vm.SetMemoryLimit(64 << 20) // 64 MiB for this VM
if err := vm.SetEvalTimeout(100 * time.Millisecond); err != nil {
panic(err)
}
if err := intl.Register(vm); err != nil {
panic(err)
}
v, err := vm.Eval(`new Date(0).toLocaleString('zh-CN', {timeZone: 'Asia/Shanghai'})`, slowjs.EvalGlobal)
fmt.Println(v, err) // 1970/1/1 08:00:00 <nil>
_, err = vm.Eval(`try { for (;;) {} } catch (e) { 'caught' }`, slowjs.EvalGlobal)
var e *slowjs.Error
if errors.As(err, &e) && e.Interrupted {
fmt.Println("timed out, and no catch saw it")
}
}A VM is not safe for several goroutines at once: give each goroutine its own, or keep a pool. Intl and Temporal use embedded tz 2026e, so they need no host zoneinfo.
The API is that of modernc.org/quickjs, documented at pkg.go.dev, with these additions:
NewBuiltin(name, length, fn)andVM.NewBuiltinFunction(b): a Go function javascript calls as it callsMath.max, with no reflection.fnreads its arguments fromArgs(Number,String,Bool,Value,This,VM, andLatin1orUTF16for the characters of a string as the engine holds them), which convert nothing, and returns aValue. A call that adds two numbers takes about 55 ns on an Apple M4, against about 470 ns throughRegisterHostFunc. Make a Builtin once per process: VMs on several goroutines call it at once. An*Errorit returns is thrown as the constructor itsNamesays (Error,TypeError,RangeError,SyntaxError,ReferenceErrororInternalError), any other error as aTypeError. MoreVM.NewArray(vals...): an array of values made in Go, made as an array literal makes one, so a setter a page put onArray.prototypenever runs. MoreVM.SetStrip(s)withtype Strip:StripSourcecompiles functions without their source, so theirtoStringshows[native code], andStripDebugleaves out line and column numbers. MoreError()of an*Errormade in Go isName: Message, asError.prototype.toStringwrites it, where it was empty. An error taken from javascript keeps the engine's text. MoreVM.RunGC(),VM.MemorySize()andVM.GCThreshold(): run a GC now, read the memory a VM takes and the threshold of its next GC, so a program can collect between two calls into javascript and set the threshold for the next one. MoreVM.RunYoungGC(): collect only the objects made since the last GC, so a server that runs a young GC after each render and a full one now and then spends on each GC about as long as the garbage of one render. MoreVM.OnClose(f): runfwhen the VM is closed, so a package that keeps something per VM in a table of its own can drop it then. MoreNewSharedBytecode(bytecode)andVM.EvalSharedBytecode(b): VMs that load the same bytecode keep the source text and line tables of its functions in one copy, so each VM of tifa's example shop holds 2.36 MiB less, with the sametoStringand stacks. More- Package
temporal:temporal.Register(vm)addsTemporal, every type and method V8 ships, computed in Go by go-intl with ICU's zones for tz 2026e built in, so the host's zoneinfo is never read.Temporal.Nowreads the VM clock, andtemporal.Clockoverrides it forTemporal.Nowalone. See the linked doc for current binary size measurements. More *ErrorfieldsUncatchable,OutOfMemoryandInterrupted: why a call ended, which no script can fake. MoreVM.SetClock(now)andVM.Now(): set and read the wall clock of a VM, which Date, Intl and Temporal all read. nil restores the live clock. MoreVM.SetShapeCache(n)andDefaultShapeCache: how many shapes of plain objects (a shape is the engine's record of which keys an object has, in which order) a VM keeps for the next object with the same keys. MoreGetAsyncContextandSetAsyncContext: two Builtins that read and set an object promise jobs carry: athenor anawaitruns with the one set when it was registered, so anAsyncLocalStoragewritten on them finds its store afterawaitas in Node. A VM that never sets one runs promises as before. More
The first five came in v0.26.17. Of the rest, the *Error fields came in v0.26.7, the shape cache in v0.26.16, VM.OnClose and package temporal in v0.27.0, VM.RunYoungGC in v0.27.3, SharedBytecode in v0.28.1, VM.SetClock and VM.Now in v0.28.2, and the async context in v0.28.5.
- A limit ends the call. A script that runs past its eval timeout or its memory limit stops there, whatever it catches: no
catch,finally, promise handler or Go function of the page gets to run on.*Errorsays whether it was a timeout or memory. A panic in a Go function you gave the VM comes back to your Go code, and a VM that ran out of memory still closes without a leak. - The same page on every machine. A script's local time is UTC in every VM, without setting
TZfor the whole process.Intlformats in every locale Chromium has, compared with Chromium 156 on about 75,000 expressions. - Strings arrive whole. A Go string from javascript is always valid UTF-8, and a Go string with invalid bytes reads in javascript as Go itself decodes it.
- Faster pages. On the React pages of the example shop of tifa, a Go framework that renders React on the server, a page takes 0.91 to 0.93 of the time of C QuickJS as tifa builds and runs it, and 1.14 to 1.15 times on its test pages that need no
Intl(see the comparison). Several VMs on several cores no longer wait on one lock.
- The engine is upstream QuickJS.
lib/is what ccgo makes from Bellard's C, merged from upstream libquickjs with its history. slowjs changes it only throughinternal/fixlib, which rewrites the generated Go mechanically, and through a C patch for a regeneration. The committed files are exactly whatfixlibmakes from the upstream files, byte for byte (how to check). - It stops when upstream moves. Every rewrite checks the code it touches against fingerprints or counts. When an upstream release changes that code,
fixlibstops and names it, rather than rewriting code it has not seen. - Every change is written down. docs/fork-changes gives each change with why it is safe, what it costs in memory, what it gains on which machine, and its tests.
- The tests are upstream's and more. test262 gives the same 58 known failures as upstream libquickjs, line for line. On top come over 600 tests of its own, and leak checks under libc's allocator that tracks every allocation.
- Someone depends on it. It is kept for tifa, a Go framework that renders React pages in it, and each release is measured on tifa's pages before tifa takes it.
The engine is QuickJS by Fabrice Bellard and Charlie Gordon, the release of 2026-06-04. Jan Mercl translated it to Go with ccgo as modernc.org/libquickjs (v0.13.1 here, under lib/), and the quickjs-go authors wrote the Go API over it, modernc.org/quickjs (v0.25.0 here, at the root). slowjs merges the two into one module, so that a fix to the engine ships with the Go API without waiting for an upstream release of either. docs/upstream.md has the exact versions, what the fork kept and changed, and what changes when you switch from modernc.org/quickjs.
| Doc | Read it when |
|---|---|
| Fork changes | You want to know what slowjs does differently from modernc.org/quickjs, and why |
| Performance | You want to know how fast each release renders, or what was tried |
| Benchmarks | You want to know how this engine compares with the other pure Go JavaScript engines |
| Changelog | You are upgrading and want to know what changed |
| Architecture | You are about to change the code |
| Testing | You are about to commit, or want to check slowjs against upstream |
| Upgrading | You are taking a new upstream release, or new locale data |
| Releasing, Commits | You are making a release or a commit |
| Upstream | You are switching from modernc.org/quickjs, or want to know where the code comes from |
The API is documented at pkg.go.dev.
The files slowjs created are under the MIT license, and its source files carry an SPDX line. 23 of them repeat parts of the engine, and their SPDX line says MIT AND BSD-3-Clause. Everything that came from upstream keeps its own license: the Go API and the engine as ccgo translated it, including the generated files in lib/ and the fork's rewrites of them, are under BSD-3-Clause (LICENSE, lib/LICENSE) and QuickJS's MIT license (LICENSE-LIBQUICKJS). slowjs's changes to those files are offered under MIT as well, and the files stay usable under their upstream licenses. The locale data that intl embeds stays under the Unicode License V3 and the other notices in intl/LICENSE-DATA, and internal/gointl, a copy of go-intl, under go-intl's Unicode License V3, with the notices of the ICU data and of the V8 and Rust code that go-intl ported, which NOTICE.md lists.
A program that ships a binary built with this module has to carry the notices of these licenses. NOTICE.md says which license covers which path, and which texts to include.