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 andRyan 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:
Originally, I wrote, that NodeJS is 34.7x faster if we go back to callbacks! 🤣 Wrong.
Not as impressive as my bad benchmarks before (and see comments for context), but still a sizeable difference.
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. 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.
And it bugs me out.
Because what does slow mean? As with all benchmarks, mine is contextual.
I started reading about the
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
Promises, however, get handled in the main, single executable thread. And async/await, is well, promises but now with blocking added.
Let's put it to the test.
First off. My machine! Complements of working with
MacBook Pro (14-inch, 2021)
Chip Apple M1 Pro
Memory 32 GB
Cores 10
NodeJS v20.8.1
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 the speed down with the heaviest objects in the universe.
First, let's start with parallelcallbacks. 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()
}
})
}
});
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)
});
Then we have async/await. My favourite way of working with Nodejs.
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");
})
})
});
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");
}
});
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.
// > 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()
})
});
Again, we want to wait for the execution of all readFile
operations.
// > 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()
}
})
}
});
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 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!
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!
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.
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, 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.
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.
Also appears here.