What’s New in Node.js 20

    Craig Buckler
    Share

    Version 20 of Node.js was released on 18 April 2023. It addresses some issues and criticisms already “solved” by Deno and Bun, including a new permission model and a stable native test runner. This article examines the new options available to developers using the world’s most-used JavaScript runtime.

    Contents:

    1. The Node.js Release Schedule
    2. New Permission Model
    3. Native Test Runner
    4. Compiling a Single Executable Application
    5. Updated V8 JavaScript Engine
    6. Miscellaneous Updates

    The Node.js Release Schedule

    Node.js has a six-month release schedule:

    • The April even-numbered releases (14, 16, 18, etc.) are stable and receive long-term support (LTS) updates for three years.

    • The October odd-numbered release (15, 17, 19, etc.) are more experimental and updates often end after one year.

    In general, you should opt for the even-numbered LTS version unless you require a specific feature in an experimental release and intend to upgrade later. That said, Node.js 20 is new and the website advises you continue with version 18 while the development team fixes any late-breaking issues.

    Node.js 20 has the following new features …

    New Permission Model

    Running node somescript.js is not without risk. A script can do anything: delete essential files, send private data to a server, or run a cryptocurrency miner in a child process. It’s difficult to guarantee your own code won’t break something: can you be certain that all modules and their dependencies are safe?

    The new (experimental) Node.js Permission Model restricts what script can do. To use it, add the --experimental-permission flag your node command line followed by:

    1. --allow-fs-read to grant read-access to files. You can limit read-access to:

      • specific directories: --allow-fs-read=/tmp/
      • specific files: --allow-fs-read=/home/me/data.json
      • or wildcard file patterns: --allow-fs-read=/home/me/*.json
    2. --allow-fs-write to grant write-access to files with identical directory, file, or wildcard patterns.

    3. --allow-child-process to permit child processes such as executing other scripts perhaps written in other languages.

    4. --allow-worker to permit worker threads, which execute Node.js code in parallel to the main processing thread.

    In the following example, somescript.js can read files in the /home/me/data/ directory:

    node --experimental-permission --allow-fs-read=/home/me/data/ somescript.js
    

    Any attempt to write a file, execute another process, or launch a web worker raises a ERR_ACCESS_DENIED error.

    You can check permissions within your application using the new process.permission object. For example, here’s how to check whether the script can write files:

    process.permission.has('fs.write');
    

    Here’s how to check if the script can write to a specific file:

    if ( !process.permission.has('fs.write', '/home/me/mydata.json') ) {
      console.error('Cannot write to file');
    }
    

    JavaScript permission management was first introduced by Deno, which offers fine-grained control over access to files, environment variables, operating system information, time measurement, the network, dynamically-loaded libraries, and child processes. Node.js is insecure by default unless you add the --experimental-permission flag. This is less effective, but ensures existing scripts continue to run without modification.

    Native Test Runner

    Historically, Node.js has been a minimal runtime so developers could choose what tools and modules they required. Running code tests required a third-party module such as Mocha, AVA, or Jest. While this resulted in plenty of choices, it can be difficult to make the best decision, and switching tools may not be easy.

    Other runtimes took an alternative view and offered built-in tools considered essential for development. Deno, Bun, Go, and Rust all offer built-in test runners. Developers have a default choice but can opt for an alternative when their project has specific requirements.

    Node.js 18 introduced an experimental test runner which is now stable in version 20. There’s no need to install a third-party module, and you can create test scripts:

    • in your project’s /test/ directory
    • by naming the file test.js, test.mjs, or test.cjs
    • using test- at the beginning of the filename — such as test-mycode.js
    • using test at the end of the filename with preceding period (.), hyphen (-) or underscore (_) — such as mycode-test.js, mycode_test.cjs, or mycode.test.mjs

    You can then import node:test and node:assert and write testing functions:

    // test.mjs
    import { test, mock } from 'node:test';
    import assert from 'node:assert';
    import fs from 'node:fs';
    
    test('my first test', (t) => {
      assert.strictEqual(1, 1);
    });
    
    test('my second test', (t) => {
      assert.strictEqual(1, 2);
    });
    
    // asynchronous test with mocking
    mock.method(fs, 'readFile', async () => 'Node.js test');
    test('my third test', async (t) => {
      assert.strictEqual( await fs.readFile('anyfile'), 'Node.js test' );
    });
    

    Run the tests with node --test test.mjs and examine the output:

    ✔ my first test (0.9792ms)
    ✖ my second test (1.2304ms)
      AssertionError: Expected values to be strictly equal:
    
      1 !== 2
    
          at TestContext.<anonymous> (test.mjs:10:10)
          at Test.runInAsyncScope (node:async_hooks:203:9)
          at Test.run (node:internal/test_runner/test:547:25)
          at Test.processPendingSubtests (node:internal/test_runner/test:300:27)
          at Test.postRun (node:internal/test_runner/test:637:19)
          at Test.run (node:internal/test_runner/test:575:10)
          at async startSubtest (node:internal/test_runner/harness:190:3) {
        generatedMessage: false,
        code: 'ERR_ASSERTION',
        actual: 1,
        expected: 2,
        operator: 'strictEqual'
      }
    
    ✔ my third test (0.1882ms)
    ℹ tests 3
    ℹ pass 2
    ℹ fail 1
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 72.6767
    

    You can add a --watch flag to automatically re-run tests when the file changes:

    node --test --watch test.mjs
    

    You can also run all tests found in the project:

    node --test
    

    Native testing is a welcome addition to the Node.js runtime. There’s less need to learn different third-party APIs, and I no longer have an excuse when forgetting to add tests to smaller projects!

    Compiling a Single Executable Application

    Node.js projects require the runtime to execute. This can be a barrier when distributing applications to platforms or users who can’t easily install or maintain Node.js.

    Version 20 offers an experimental feature which allows you to create a single executable application (SEA) that you can deploy without dependencies. The manual explains the process, although it’s a little convoluted:

    1. You must have a project with a single entry script. It must use CommonJS rather than ES Modules.

    2. Create a JSON configuration file used to build your script into a blob which runs inside the runtime. For example, sea-config.json:

      {
        "main": "myscript.js",
        "output": "sea-prep.blob"
      }
      
    3. Generate the blob with node --experimental-sea-config sea-config.json.

    4. According to your OS, you must then copy the node executable, remove the binary’s signature, inject the blob into the binary, re-sign it, and test the resulting application.

    While it works, you’re limited to older CommonJS projects and can only target the same OS as you’re using. It’s certain to improve, given the superior Deno compiler can create an executable for any platform in a single command from JavaScript or TypeScript source files.

    You should also be aware of the resulting executable’s file size. A single console.log('Hello World'); generates a file of 85MB, because Node.js (and Deno) need to append the whole V8 JavaScript engine and standard libraries. Options to reduce file sizes are being considered, but it’s unlikely to go below 25MB.

    Compilation won’t be practical for small command-line tools, but it’s a more viable option for larger projects such as a full web server application.

    Updated V8 JavaScript Engine

    Node.js 20 includes the latest version of the V8 engine, which includes the following JavaScript features:

    Miscellaneous Updates

    The following updates and improvements are also available:

    Summary

    Node.js 20 is a major step forward. It’s a more significant release, and implements some of Deno’s better features.

    However, this begs the question: should you use Deno instead?

    Deno is great. It’s stable, natively supports TypeScript, reduces development times, requires fewer tools, and receives regular updates. On the downside, it’s been around less time, has fewer modules, and they’re often shallower imitations of Node.js libraries.

    Deno and Bun are worth considering for new projects, but there are thousands of existing Node.js applications. Deno and Bun are making it easier to transition code, but there won’t always be a clear advantage for moving away from Node.js.

    The good news is we have a thriving JavaScript ecosystem. The runtime teams are learning from each other and rapid evolution benefits developers.