Skip to main content

Command Palette

Search for a command to run...

NodeJS: 4.8x faster if we go back to callbacks!

Updated
8 min read
NodeJS: 4.8x faster if we go back to callbacks!
G

I'm a Senior Software Engineer. With 10+ years working within tech teams, and 20+ years working with code, I develop across the stack, assisting with application design, maintenance, deployment and DevOps within the AWS Cloud.

Yeah, I said it!

Callbacks are 4.8x faster when running them parallel over async/await in parallel. And only 1.9x faster when we run sequential callbacks.

I've modified this article somewhat after I got some helpful and kind comments about my dodgy test. 😂🙏

Thank you to Ricardo Lopes and Ryan Poe for taking the time to steer the benchmarks in the right direction. My first faux pas was I wasn't actually waiting for the execution of the code to finish, which crazily skewed the results. The second was I was comparing parallel to sequential runtimes, which make the benchmarks worthless.

So this is round 2 which addresses my initial errors. Previously, I said:

NodeJS: 34.7x faster if we go back to callbacks! I shouldn't have believed it. 🤣

Not as impressive as my bad benchmarks before (and see comments for context), but still a sizeable difference.

So what exactly did I test?

I compared callbacks to promises and async/await when reading a file, 10,000 times. And maybe that's a silly test but I wanted to know, which is faster at I/O.

Then I finally compared callbacks in Node.js to Go!

Now, guess who won?

I won't be mean. TLDR. Node.js callbacks Golang!

Lower is better. Results are in ms.

Now, true benchmarkers? Go easy on me on this one. But please do leave your comments to make me a better person.

Everyone keeps saying that Node.js is slow!

And it bugs me out.

Because what does slow mean? As with all benchmarks, mine is contextual.

I started reading about the Event Loop, just to even begin to understand how it works.

But the main thing I've understood is that Node.js passes I/O tasks onto a queue that sits outside the main Node.js executable thread. This queue runs on pure C. A number of threads could potentially handle these I/O operations. And that's where Node.js can shine, handling I/O.

Promises, however, get handled in the main, single executable thread. And async/await, is well, promises but now with blocking added.

Event loop consisting of 6 different queues.

So are callbacks faster than promises?

Let's put it to the test.

First off. My machine! Complements of working with Kamma. It's important to note what resources we're working with. Plenty memory and CPU.

MacBook Pro (14-inch, 2021)
Chip      Apple M1 Pro
Memory    32 GB
Cores     10
NodeJS    v20.8.1
Go        1.21.0

So we have a text.txt file with an original message, Hello, world.

echo "Hello, world" > text.txt

And we'll read this text file using native Node.js, which means, zero node module dependencies because we don't want to drag node modules down with the heaviest objects in the universe.

Heaviest Objects In The Universe : r/ProgrammerHumor

Callbacks

Parallel callbacks

First, let's start with parallel callbacks. I'm interested in how quickly the same file can be read as quickly as possible, all at once. And what's faster than parallel?

// > file-callback-parallel.test.mjs
import test from 'node:test';
import assert from 'node:assert';
import fs from "node:fs";

test('reading file 10,000 times with callback parallel', (t, done) => {
    let count = 0;
    for (let i = 0; i < 10000; i++) {
        fs.readFile("./text.txt", { encoding: 'utf-8'}, (err, data) => {
            assert.strictEqual(data, "Hello, world");
            count++
            if (count === 10000) {
                done()
            }
        })
    }
});

Sequential callbacks

Second, we have callbacks again, but sequential (or rather blocking). I'm interested in how quickly the same file can be read sequentially. Having not done callbacks calling callbacks for ages, this was fun to try again. Albeit, it doesn't look pretty.

// > file-callback-blocking.test.mjs
import test from 'node:test';
import assert from 'node:assert';
import fs from "node:fs";

let read = (i, callback) => {
    fs.readFile("./text.txt", { encoding: 'utf-8'}, (err, data) => {
        assert.strictEqual(data, "Hello, world");

        i += 1

        if (i === 10000) {
            return callback()
        }

        read(i, callback)
    })
}

test('reading file 10,000 times with callback blocking', (t, done) => {
    read(0, done)
});

Async/Await

Then we have async/await. My favourite way of working with Nodejs.

Parallel async/await

It's as parallel as I can get with async/await. I load all the readFile operations into an array and await them all using Promise.all.

// > file-async-parallel.test.mjs
import test from 'node:test';
import assert from 'node:assert';
import fs from "node:fs/promises";

test('reading file 10,000 times with async parallel', async (t) => {
    let allFiles = []
    for (let i = 0; i < 10000; i++) {
        allFiles.push(fs.readFile("./text.txt", { encoding: 'utf-8'}))
    }

    return await Promise.all(allFiles)
        .then(allFiles => {
            return allFiles.forEach((data) => {
                assert.strictEqual(data, "Hello, world");
            })
        })
});

Sequential Async/Await

This was the easiest and most concise one to write.

// > file-async-blocking.test.mjs

import test from 'node:test';
import assert from 'node:assert';
import fs from "node:fs/promises";

test('reading file 10,000 times with async blocking', async (t) => {
    for (let i = 0; i < 10000; i++) {
        let data = await fs.readFile("./text.txt", { encoding: 'utf-8'})
        assert.strictEqual(data, "Hello, world");
    }
});

Promises

Finally, we have promises without async/await. I've long stopped using them in favour of async/await but I was interested in whether they were performant or not.

Parallel promises

// > file-promise-parallel.test.mjs
import test from 'node:test';
import assert from 'node:assert';
import fs from "node:fs/promises";

test('reading file 10,000 times with promise parallel', (t, done) => {
    let allFiles = []

    for (let i = 0; i < 10000; i++) {
        allFiles.push(fs.readFile("./text.txt", { encoding: 'utf-8'}))   
    }

    Promise.all(allFiles)
        .then(allFiles => {
            for (let i = 0; i < 10000; i++) {
                assert.strictEqual(allFiles[i], "Hello, world");
            }

            done()
        })
});

Sequential promises.

Again, we want to wait for the execution of all readFile operations.

This doesn't seem quite right as it's technically still not waiting for each to finish.

// > file-promise-blocking.test.mjs
import test from 'node:test';
import assert from 'node:assert';
import fs from "node:fs/promises";

test('reading file 10,000 times with promises blocking', (t, done) => {
    let count = 0;
    for (let i = 0; i < 10000; i++) {
        let data = fs.readFile("./text.txt", { encoding: 'utf-8'})
            .then(data => {
                assert.strictEqual(data, "Hello, world")
                count++
                if (count === 10000) {
                    done()
                }
            })
    }
});

How I ran the tests

And voila! Results 🎉! I even ran it a few times to get a better reading.

I ran each test by doing:


node --test <file>.mjs

Reading a file 10,000 times with callbacks is over 34x 5.8x faster than with async/await in parallel! It's also 4.7x faster than with promises in parallel!

So, in Node.js land, callbacks are more performant!

Now is Go faster than Node.js?

Well, I don't write in Go, so this may be truly terrible code because I asked ChatGPT to help me and yet, it seems pretty decent.

Hey ho. Let's go. Our Golang code.

package main

import (
    "fmt"
    "io/ioutil"
    "time"
)

func main() {
    startTime := time.Now()

    for i := 0; i < 10000; i++ {
        data, err := ioutil.ReadFile("./text.txt")
        if err != nil {
            fmt.Printf("Error reading file: %v\n", err)
            return
        }
        if string(data) != "Hello, world" {
            fmt.Println("File content mismatch: got", string(data), ", want Hello, world")
            return
        }
    }

    duration := time.Since(startTime)
    fmt.Printf("Test execution time: %v\n", duration)
}

And we run it as so:

go run main.go

And the results?

Test execution time: 58.877125ms

🤯 Go is 4.9x faster than Node.js using sequential callbacks. Node.js only comes close with parallel execution.

Node.js Async/await is 9.2x slower than Go.

So yes. Node.js is slower. Still, 10,000 files in sub 300ms isn't to be scoffed at. But I've been humbled by Go's speediness!

Now just a side note. Do I have bad benchmarks?

I really did have terrible Benchmarks. Thank you again to Ricardo and Ryan.

Yes, I did. Hopefully now they're better.

But you may ask, who's really going to read the same file, over and over again? But for a relative test between things, I hope it's a helpful comparison.

I also don't know how many threads Node.js is using.

I don't know how my CPU cores affect Go vs Node.js performance.

I could just rent an AWS machine with one core and compare.

Is it because I'm on Mac M1?

How would Node.js perform on a Linux or...Windows? 😱

And there's the practicality of, yes, reading a file is one thing, but at some point, you have to wait anyway for the file to be read to do something with the data in the file. So, speed on the main thread is still pretty important.

Now, do you really want to use callbacks?

I mean, do you really, really want to?

I don't know. I definitely don't want to tell anyone what to do.

But I like the clean syntax of async/awaits.

They look better.

They read better.

I know better is subjective here but I remember callback-hell, and I was grateful when promises came into existence. It made Javascript bearable.

Now, while Golang in this instance is slower Golang is clearly faster than Node.js at its optimum, with callbacks, and with async/await, by 9.2x! So if we want good readability and performance, Golang is the winner. Although, I'd love to learn how Golang looks under the hood.

Anywho. This was fun. It was more of an exercise to help me understand how callbacks and I/O work in the Event Loop.

So to sign out

Is Node.js slow? Or are we just using Node.js on slow mode?

Probably where performance matters, Golang is worth the jump. I'll certainly be looking more at using Golang in future.

Updates

Sequential promises

If I rewrite it as below, I'm still using callbacks 😬 but at least it's sequential:

import test from 'node:test';
import assert from 'node:assert';
import fs from "node:fs/promises";

let read = (i, callback) => {
    let data = fs.readFile("./text.txt", { encoding: 'utf-8'})
        .then(data => {
            assert.strictEqual(data, "Hello, world")
            i += 1

            if (i === 10000) {
                return callback()
            }

            read(i, callback)
        })
}

test('reading file 10,000 times with promises blocking', (t, done) => {
    read(0, done)
});

I didn't include the above in the benchmarks but it is slower and not surprisingly so at 532.777667ms.

S

I've modified the tests a little to be more like-for-like, added some alternative async/await implementations, and put them up on my github (can't link because I'm newly registered)

My main takeaways are:

  • The blocking promise version is now ~30% slower than the parallel one.
  • Parallel promises and async/await appear to be about 3.4-3.7x slower than the parallel callback version on my machine. Take the results with a massive pinch of salt since runtime variance is about 200ms for some reason.

This is a much older machine running Windows and doing 20% less iterations (due to open file limits on Windows) so the numbers are not quite directly comparable, but hopefully relative scaling remains mostly intact.

1
G

Nice! Thanks Simonas Urbelis. Thanks for checking through it too. That's a really interesting find.

I would love to be able to run the same code across different machines with different specs. My hunch was that the number of cores on a machine had a big effect on parallel performance too.

It's a shame you can't share your GitHub implementation. That would be lovely to see.

My aim, unless someone does it before me is to create a GitHub repo and be able to take advantage of their machines to run the tests and benchmark them too. That'll make the tests more fair. True.

Also, I have to decide whether I want to test just blocking or truly sequential. So as someone mentioned the node:test overhead, I may have to remove node:test completely so there are no promises or async awaits being used in the callback tests at all.

Thank you again for your comment. I truly appreciate it.

S

Looks like node:test side effect. In my tests, promises have almost the same speed.

My tests:

with node:test (node v21.4.0):

✔ reading file 10,000 times with callback parallel (401.3785ms)
✔ reading file 10,000 times with callback blocking (711.3979ms)
✔ reading file 10,000 times with async parallel (512.4789ms)
✔ reading file 10,000 times with async blocking (1080.5988ms)
✔ reading file 10,000 times with promise parallel (474.045ms)
✔ reading file 10,000 times with promises blocking (755.0493ms)

without node:test (node v21.4.0):

reading file 10,000 times with callback parallel: 402.4896ms
reading file 10,000 times with callback blocking: 755.3545ms
reading file 10,000 times with async parallel: 413.0469ms
reading file 10,000 times with async blocking: 825.5176ms
reading file 10,000 times with promise parallel: 408.7469ms
reading file 10,000 times with promises blocking: 775.5422ms

without node:test (bun 1.0.26):

reading file 10,000 times with callback parallel: 580.7146ms
reading file 10,000 times with callback blocking: 437.1434ms
reading file 10,000 times with async parallel: 589.4524ms
reading file 10,000 times with async blocking: 397.4406ms
reading file 10,000 times with promise parallel: 584.5042ms
reading file 10,000 times with promises blocking: 415.0018ms

My test code:

// const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');

const tests = [];

const test = async (name, callback) => {
  tests.push([name, callback]);
};

setTimeout(async () => {
  for await (let item of tests) {
    const [name, callback] = item;

    const startTime = performance.now();

    let done;

    const promise = new Promise((resolve) => {
      done = () => {
        resolve();
        console.log(`${name}: ${(performance.now() - startTime).toFixed(4)}ms`);
      };
    });

    const res = callback(null, done);
    if (res instanceof Promise) {
      await res;
      done();
    }

    await promise;
  }
}, 0);



test('reading file 10,000 times with callback parallel', (t, done) => {
  let count = 0;
  for (let i = 0; i < 10000; i++) {
      fs.readFile("./text.txt", { encoding: 'utf-8'}, (err, data) => {
          assert.strictEqual(data, "Hello, world");
          count++
          if (count === 10000) {
              done()
          }
      })
  }
});

let read = (i, callback) => {
  fs.readFile("./text.txt", { encoding: 'utf-8'}, (err, data) => {
      assert.strictEqual(data, "Hello, world");

      i += 1

      if (i === 10000) {
          return callback()
      }

      read(i, callback)
  })
}

test('reading file 10,000 times with callback blocking', (t, done) => {
  read(0, done)
});

test('reading file 10,000 times with async parallel', async (t) => {
  let allFiles = []
  for (let i = 0; i < 10000; i++) {
      allFiles.push(fs.promises.readFile("./text.txt", { encoding: 'utf-8'}))
  }

  return await Promise.all(allFiles)
      .then(allFiles => {
          return allFiles.forEach((data) => {
              assert.strictEqual(data, "Hello, world");
          })
      })
});

test('reading file 10,000 times with async blocking', async (t) => {
  for (let i = 0; i < 10000; i++) {
      let data = await fs.promises.readFile("./text.txt", { encoding: 'utf-8'})
      assert.strictEqual(data, "Hello, world");
  }
});

test('reading file 10,000 times with promise parallel', (t, done) => {
  let allFiles = []

  for (let i = 0; i < 10000; i++) {
      allFiles.push(fs.promises.readFile("./text.txt", { encoding: 'utf-8'}))
  }

  Promise.all(allFiles)
      .then(allFiles => {
          for (let i = 0; i < 10000; i++) {
              assert.strictEqual(allFiles[i], "Hello, world");
          }

          done()
      })
});

let read2 = (i, callback) => {
  let data = fs.promises.readFile("./text.txt", { encoding: 'utf-8'})
      .then(data => {
          assert.strictEqual(data, "Hello, world")
          i += 1

          if (i === 10000) {
              return callback()
          }

          read(i, callback)
      })
}

test('reading file 10,000 times with promises blocking', (t, done) => {
  read2(0, done)
});
1
G

Thank you, Sergei. That's a very important point. I noticed when I ran all the tests together, it increased all the times. So you make a good point that node:test may have a greater effect than I expected. Hopefully the results still work out relatively but still, it's definitely worth comparing.

With the promises test I tried to avoid using await, so I'm not sure if that is causing the increase in the times you saw.

But I'd love to take your example and tweak it without node:test and without the await/async in the promises test to see the results.

And thank you for taking the time to read through the benchmarks. I really appreciate that too. And helping pointing out any flaws in my benchmarks. It's so helpful.

R

Interesting benchmarking work, and surprising results! I think I found why it looks so skewed against async/await.

Looking at the docs, it looks like the callbacks test is missing a call to the second argument of the test callback function. This is what test runners use to know that async work has finished. Otherwise, the test is firing 10000 iterations without waiting for the previous ones to finish, unlike what's happening in the async/await test. I imagine the same must be happening with promises.

So, for a fairer comparison, I believe either the callback and promises tests should call the second argument of the test callback to block like the async/await, or the async/await test should drop the await so that it fires all iterations at once like the others.

In any way, I agree that always blocking on any async work is slow mode and no better than other languages. It's when we can parallelise multiple async work that node truly shines!

1
G

Oooooh. Really good point, Ricardo. Let me address that and re-run those tests. Yes. I told you I was a really bad benchmarker! 😂 test('callback passing test', (t, done) should help.

G

Yep. That changes things a lot! 😂. I'll do an edit!

image.png

And thank you for taking the time to read 🙏

R

Gemma Black even that may not be enough. If I'm reading it right, the callbacks test is only blocking once, and it's still running 10000 reads in parallel, while the async test is blocking 10000 times to run reads sequentially. You may need to launch the next readFile only after the previous one completed (ie inside it's callback).

1
G

👏👏👏 True Ricardo Lopes. I should compare blocking to blocking.

One thing that came to me was also resource usage. I hope you won't mind me mentioning you in the article as a helpful reviewer too?

R

Gemma Black of course, glad I could help :)

1

More from this blog

G

Gemma Black - Senior Software Engineer

21 posts