How Node.js and V8 keep each other working - workflows, challenges and tips
Recently I’ve found myself repeating the same explanations to different people about how to test a V8 patch in Node.js, how to patch the Node.js fork run in V8’s integration CI, or how to get these patches into their repositories/CIs. I’ve also been helping out with some of the V8 maintenance tasks in Node.js recently, which refreshed some of my knowledge about how things work these days. To reduce the parrotting, here is my attempt to write down the current state of things, the challenges I’ve observed and some tips on how to handle cross-project patches.
A map of repositories
Before we begin, here’s a quick map of the repositories involved in the Node.js and V8 integration:
nodejs/node: the main Node.js repository.- The
mainbranch is the primary development branch. - The
canary-basebranch is the base for testing newer versions of V8.
- The
nodejs/node-v8: the repository for testing V8 integration with Node.js. Known as “canary”.- The
mainbranch is used to run a daily workflow that testscanary-basewith the latest V8lkgr(Last-Known-Good-Revision) branch. - The
canarybranch is updated daily by the aforementioned workflow if it succeeds, and gets built by a daily release CI to produce v8-canary builds.
- The
v8/v8: the main V8 repository.v8/node: the repository for testing Node.js integration with V8.- The
node-ci-YYYY-MM-DDbranch e.g.node-ci-2026-04-16is used in V8’s integration CI, and is cut from the Node.jsmainbranch with V8’s own patches applied on top every few months.
- The
v8/node-ci: the repository for managing V8’s Node.js integration CI, which includes the build scripts and dependency settings.
deps/v8 in Node.js
I wrote about this several years ago in this blog post, most of that is still true today, but here’s a refreshed description of it.
How Node.js maintains V8 updates
The source of truth about this lives in this document. Here is my rephrase of what the process means in practice.
Node.js maintains a fork of V8 in this deps/v8 directory. On the main branch, this directory is regularly updated to stay in sync with the most recent V8 stable releases. It also “floats” some V8 patches on top - usually changes that have already landed in V8’s main branch but backported early to Node.js’s fork to speed up the roundtrip, or minimal platform-specific patches that make Node.js work with platforms/toolchains that V8 doesn’t support upstream. Other kinds of modifications to deps/v8 are generally expected to be reviewed and landed in the V8 upstream, in order to minimize the discrepancies and the maintenance burden.
Roughly every month, when a new V8 stable release is out, a big PR like this will be opened to update the deps/v8 directory to the new version and make it work with Node.js. In the many years I’ve been with the project, it’s usually Michaël Zasso who takes on the heroic task of opening these huge PRs and collaborating with everyone to get it working on the many platforms in the Node.js CI.
Other than the main branch, there is a canary-base branch in Node.js that serves as the base branch for testing newer versions of V8. Every day, a workflow runs in the nodejs/node-v8 repository to pull the canary-base branch of Node.js, updates its deps/v8 directory to be a copy of V8’s lkgr (Last-Known-Good-Revision) branch with floated patches on top, and checks if it builds. This canary workflow helps contributors stay on top of the upcoming breakages before they pile up in the big upgrade PRs. When the workflow succeeds, it updates the canary branch in nodejs/node-v8, and a daily release CI will build it to produce binaries in the v8-canary release channel.
How Node.js builds V8
This is the part that seems to surprise many new Node.js contributors - the V8 upstream uses GN as the build system, while Node.js uses GYP (or technically speaking, it’s a GYP-next fork living in tools/gyp). This is a legacy build system used by Chromium/V8 before they switched to GN, but Node.js has been stuck with it due to various downstream compatibility reasons - there have been many discussions (1, 2, 3) about moving away from GYP, but for now GYP is still in use. A major part of maintaining the V8 updates in Node.js comes down to translating upstream GN config changes into GYP config changes - there are some scripts that help with the translation, though the difficult part is often to ensure the intent of the changes remains the same or is adapted correctly for Node.js’s use cases. The GYP files for building V8 don’t live in deps/v8 but in tools/v8_gypfiles to avoid conflicts.
As a server-side runtime, Node.js supports many operating systems / architectures / toolchains, both in building and in running itself, that upstream V8 (which is mainly built for the client side) doesn’t support. The support gap is also another major source of Node.js’s V8 maintenance work. For example, upstream V8 only officially supports and tests building with a pinned version of Clang/LLVM (very close to tip-of-tree) and statically compiles with a pinned version of libc++ on all platforms. Node.js, on the other hand, supports and tests building with GCC and dynamically linking to libstdc++ on Linux, or building with ClangCL and linking to Microsoft’s C++ standard library on Windows (the supported versions tend to align with the ones supported by their respective platforms). V8 also uses nearly-tip-of-tree rustc to compile its Rust dependencies e.g. temporal_rs, while Node.js supports building with statble rustc. Patches that address the support gaps are also usually upstreamed to V8 if deemed acceptable, or in rare cases, floated in deps/v8. When the maintenance burden of supporting a platform is too much and there’s not enough volunteers taking over the work, a platform may be demoted to experimental (like this).
V8 provides many build configurations, with defaults tuned for client-side embedders (specifically Chromium). Node.js uses a different set of build configurations for its own use cases. For example, upstream V8 turns on pointer compression by default (which trades memory efficiency for a 4GB hard heap size limit), while Node.js turns it off for backwards compatibility. The different code paths taken by these different combinations of configurations aren’t always tested in the V8 upstream, so breakages from different build configurations are another source of maintenance work. Removal or addition of build configurations in V8 often leads to some tricky judgement calls about what combination works best for Node.js’s audiences and maintainers. Occasionally it requires a decision to change the configurations in Node.js (like this) in order to keep the maintenance burden manageable.
How Node.js tests V8 updates
When deps/v8 is changed in a PR in Node.js, other than running the regular Node.js tests, contributors also run a subset of V8 tests to make sure that the fork is working according to V8’s expectations. This is done through a job in the Node.js Jenkins CI, which runs make test-v8, currently only on Linux.
V8 updates in Node.js release lines
For the past few years, Node.js has been maintaining strict ABI compatibility within each major release - in practice, this means:
- The Node.js ABI version
process.versions.modulesis frozen in each major release line. - A Node.js addon compiled against Node.js v24.x, for example, is guaranteed to load for any v24.x release, which can be important for addons distributed using tools like node-pre-gyp.
Because Node.js exposes V8 headers directly to native addons, and V8 doesn’t maintain ABI compatibility across versions, this poses a challenge in upgrading V8 in existing releases. In previous years, some contributors would take on the tedious work to review the ABI changes of a V8 upgrade and patch it so that a V8 upgrade can land on an existing release without breaking the ABI. This requires a lot of judgement calls about behavioral assumptions in the wild and can be a bit too much for a project maintained by volunteers, so in recent years the V8 major version tends to be frozen in each major release line. It also means bug fixes etc. in newer V8 versions can be difficult to backport to old Node.js LTS and these backports tend to be avoided unless necessary e.g. when it’s related to security.
Combined with the way Node.js LTS schedule works, the V8 version lock-in also means that every April, there’s a rush to get the latest-in-April V8 version into the new even-numbered release to prevent the LTS from carrying a very old version of V8, and this was the main reason why both in 2025 and 2026, the even-numbered releases were delayed until May. I wrote about some of the challenges in the 2025 April V8 upgrade in this Bluesky thread. The delay in 2026 was for similar reasons. This pressure, along with the shift of the addon ABI landscape brought by the maturity of Node-API and the new built-in node:ffi support, motivated a recent discussion to decouple the ABI version from the Node.js major release version.
The Node.js integration CI in V8
I am not on the Google V8 team though I contribute to V8 regularly. The following notes are only based on my experience working with the V8 upstream.
How V8 builds and tests Node.js
V8 also tests Node.js in its own integration CI. In practice this means V8 CLs cannot land on V8’s main branch without passing tests in V8’s Node.js fork.
V8’s Node.js integration CI is managed through v8/node-ci, which contains the build scripts and dependency settings. The actual Node.js fork being tested lives in v8/node. Every few months, a new branch is cut from the Node.js main branch with V8’s own patches applied on top. It’s named after the date node-ci-YYYY-MM-DD and used in V8’s CI.
V8 tests their fork of Node.js using GN. Since Electron also uses GN to build Node.js with their own patches, maintainers of Electron have upstreamed their GN configs to Node.js, and both V8 and Electron use the unofficial GN configs living in Node.js upstream. The load-bearing part of these configs are named unofficial.gni in various directories (e.g. like this one), so that Node.js contributors are aware these are not used in the official testing/release CI of Node.js - to apply build changes for Node.js’s testing/release CI, they need to update the GYP files instead.
V8’s GN build of Node.js works differently from the upstream GYP build - for example, just like how V8 builds itself in the upstream, this fork uses a pinned version of Clang/LLVM and statically compiles libc++ in. It also turns on pointer compression, etc. and uses siso for building, while Node.js upstream generally uses make or ninja. This diversity was sometimes useful when isolating the cause of regressions (see my investigation of Node.js startup performance regression on macOS for an example), but it also means that some breakages won’t surface in the V8 CI and Node.js contributors will only find out from the nodejs/node-v8 canary. API breakages are still usually caught and dealt with by V8 contributors before they land in V8’s main branch, regardless of build differences. When this happens, the V8 contributors often submit a PR to the node-ci-YYYY-MM-DD branch in v8/node to make the two work together so that the V8 CL can land, and Node.js contributors usually port the fix later from v8/node to nodejs/node-v8 to keep the canary green or get the big upgrade PRs working.
Tips for cross-project patches
Testing a V8 patch in Node.js
To see the actual impact of a V8 change on Node.js users, for example if you want to gauge the performance impact of a V8 change in Node.js, then it’s better to test in the GYP build because the performance characteristics can differ a lot in the GN build and won’t be representative of what Node.js users will see. For this I’d usually just clone the nodejs/node repository, check out the main branch, and then apply the V8 patch on top of the deps/v8 directory.
Because deps/v8 is not tracked as actual git repository/submodule (though there has been a long-standing proposal about that), it takes a bit of work to get the patch applied correctly.
The git-node-v8 command from node-core-utils provides some help with this, for example:
1 | cd /path/to/node/repo |
git node v8 backport will update the common.gypi so that the embedder suffix in process.versions.v8 gets bumped correctly. It also formats the commit message correctly according to the Node.js conventions. This command works fine if the patch lands cleanly in deps/v8, but when there are conflicts, it’s sometimes necessary to re-apply the patch and resolve the conflicts manually. Usually, I do something like this on macOS:
1 | cd /path/to/v8/repo |
When resolving conflicts on newer target branches, this is usually trivial, but V8 changes its internal memory management quite often and when any of these large-scale refactorings surfaces as a divergence in the target branch, it can take extra care to ensure the patch is applied correctly without tripping over GC issues.
Floating a V8 patch in Node.js
If the patch has already landed in the V8 upstream but you want to get it into Node.js faster than waiting for the big upgrade PRs, or if it needs to be backported to an older Node.js release line, or if you just want to see if it works in the Node.js CI before upstreaming it to V8, you can usually just submit a PR to Node.js with the patch cherry-picked on top of the target branch, like this.
Landing a Node.js patch alongside a V8 change
Sometimes, the new V8 patch needs a change in Node.js to work properly. This needs to be submitted as a commit with the V8 commit together in the same PR (like this). There’s an exception for the canary update: contributors with commit access sometimes just push a fix to canary-base directly to make the canary green again ASAP, which is okay since the commit will eventually surface in the big V8 upgrade PRs later and be reviewed there before it gets into the main branch.
Testing a Node.js patch for V8’s Node.js integration CI
If you are working on a V8 patch that breaks V8’s Node.js integration CI, and it needs a patch in Node.js to get them to work together, usually you need to test it with the GN build. This is how I usually do it:
1 | cd /path/to/node-ci/node-ci |
Upstreaming a Node.js patch to V8’s Node.js integration CI
When it’s necessary to update the version of Node.js used in V8’s Node.js integration CI, submit a PR to v8/node targeting the node-ci-YYYY-MM-DD branch (like this), and when it’s merged, submit another CL to node-ci to update the DEPS file and point to the new commit (like this).
Sometimes it’s necessary to request rolling in a Node.js update i.e. a new node-ci-YYYY-MM-DD branch cut in v8/node with the latest main branch of Node.js, in order to pull in recent changes in the Node.js upstream. In this case I’d take a look at the recent CLs in node-ci and figure out who to ping to request a new update.