Is Go a more powerful solution than Node.js for Back-End Web development?

In short: Go is a much more powerful back-end solution than Node.js and I’m wondering why Node is still even considered a “back-end solution” rather than a “prototyping tool” at all.

I’ve been doing a lot of API development in Node.js before I discovered Go and it wasn’t very pleasurable for many reasons that I’m going to list below, those reasons even got me started looking for an alternative back then.

1. Weak Dynamic Typing (JS) vs Strong Static Typing (Go)

I initially had a C++ background and when I started writing servers in JavaScript it felt like a step back because a lot of runtime errors in Node were type errors. I usually ended up wasting my time on unit- and integration tests too many of which were just testing the scripts for type correctness. Projects usually grow in size over time (even those you wouldn’t expect would) which leads to maintenance hell without proper typing.

Go won’t even let you do such mistakes since it’s strongly statically typed and will fail at compile time rather than runtime. It can only fail at runtime if you’re using empty interfaces and type assertions a lot and you’re not doing it carefully enough (you shouldn’t abuse empty interfaces anyway, structs are there for a reason).

TypeScript does improve the situation, but you have to take into account that most of the JavaScript code out there is still untyped.

2. Performance and Resource Usage

Go is fast, really fast, and it surpasses Node.js on both I/O and number crunching by a lot. I once helped a friend of mine to compare the actual number crunching performance of JS and Go using a prime-number calculation algorithm implemented similarly in both and we were a little surprised by the results when calculating 1 million primes on a late 2013 Macbook:

  • Node took 14 seconds to finish and required ~27,5 mb of memory
  • A single goroutine took only 663 milliseconds and only ~2 mb of memory

For 10 million primes things started to look even worse for Node:


  • Node took ~7m 39s and required ~234 mb of memory
  • A single goroutine took only ~7,5s and only ~13,7 mb of memory

Please, notice that this is a pure synthetic test, real-world application performance must be measured carefully in order to make valid assumptions on when it’s the platforms fault. Do not trust these numbers, better go test it yourself or look at different popular benchmarks such as Go vs Node js - Which programs are faster? because benchmarks often tend to be unintentionally falsified.

I personally never saw Node.js outperform Go, ever, on anything including I/O, which doesn’t necessarily have to universally be true, it’s just my subjective personal experience from the past 4–5 year of backend development. There might be cases, where a perfect combination of C++ and Node could easily outperform Go on specific tasks.

3. Predictability and Benchmarking

Probably the biggest problem with dynamic languages like JavaScript is the fact, that it’s incredibly hard to predict what it will actually do at runtime when looking at the code!

Even though Go uses garbage collection for memory management just like JavaScript, it’s still way more predictable in terms of performance and latency than JavaScript, because it doesn’t rely on JIT compilation but static ahead-of-time compilation instead. You can literally get the assembly code from the compiler to have a better understanding of what the CPU is going to be doing in each function!

A struct with an unsigned 32-bit integer field in Go is going to be just that, a 4-byte piece of memory, more or less (not taking padding etc. into account, but still). An object with a number property in JavaScript, however, could be anything in memory and it depends on what the particular version of the V8 engine thinks is better in a particular situation.

JavaScript is incredibly difficult to benchmark! There was a talk from Vyacheslav Egorov, at the GOTO Conference 2015 in Chicago, about how tricky benchmarking JavaScript really is, and even though the talk is from 2015 - not much has changed so far because this is an inherent problem of dynamic JIT-compiled languages in general. It’s very easy to come up with false conclusions when benchmarking JavaScript code.

Finding out where, when and why your Node.js server is slow or uses up too much system resources can also be very painful.

Contrary, Go has built-in benchmarking tools, profilers and even a neat tracer to help you identify latency problems!

The picture above shows a trace captured from a WebSocket server written in Go during performance benchmarking of webwire-go
The picture above shows a trace captured from a WebSocket server written in Go during performance benchmarking of webwire-go.

Go compilers sure don’t optimize your code as well as those of C/C++ or Rust, but the Go toolchain is very good at helping you find bottlenecks and hidden performance and resource usage potential in your programs.

Even micro-benchmarks such as “testing what string concatenation method is the fastest” is totally doable in Go and I even do it from time to time to make sure my code’s not only maintainable but also efficient at the same time.

4. Event Loop (Node) vs Goroutines (Go)

Node’s concurrency model is actually simpler, but Go’s concurrency is exceptionally powerful yet relatively easy.

As you probably already know, Node.js uses an event loop with libuv underneath which allows your Node.js server to deal with I/O asynchronously without blocking the CPU when you’re waiting for a long-running operation like an HTTP request or a Database transaction to finish. But Node.js is still inherently single-threaded, meaning that whenever your script is executed - your application code can only do 1 thing at a time. The JavaScript engine and its internal modules may be doing things concurrently, but those aren’t part of your application code and I’ve not seen many people who actually spend their time writing Node.js V8 modules in C++ to offload some application workloads onto them, because that’s not easy and you definitely don’t want to split your application code base into individual JavaScript and C++ modules.

Most JavaScript environments including Node.js do provide the concept of workers (which are kind of isolated engine instances on top of OS threads, but the actual implementation heavily depends on the engine), but…

  • you can’t spawn too many of them and…
  • they can’t share memory but instead, rely on message passing for communication.
Node.js clusters (when you run multiple processes as a cluster) also can’t share memory and have to rely on different (mostly operating-system specific) IPC techniques, or, even worse, external queue systems or databases (I’ve often seen people use Redis for that) to exchange data between individual Node instances.


Go is inherently concurrent and I’ve written about it in many of my blog posts so please be welcome to check it out.

In short, Go’s goroutines can share memory and you can spawn and use millions of them to do a lot of things concurrently! But the “sharing memory” part is where you have to be careful!

In Go, reading shared memory from multiple goroutines is fine as long there’s no goroutine trying to write to it as well. If at least one goroutine is going to be writing to a shared piece of memory - you need to protect it using a synchronization primitive (such as a mutex), otherwise, you’ll end up creating a data race!

Fortunately, Go comes with 2 built-in things to help you write safe concurrent code:

  • Go channels are safe in terms of concurrency and beginners should use those for making goroutines talk to each other safely! They’re a core part of the language and come from the concept of Communicating Sequential Processes.
  • The built-in data race detector helps you find any occurring data races at runtime so you won’t miss them.

I’ve even seen people applying strategies like running their production code with the race detector enabled (which does have a significant memory and performance impact) and then, over time, getting rid of it when they’re sure everything’s working fine boosting the performance to the top again.

5. Promises / Async&Await (JS) vs nothing (Go)

Since JavaScript is inherently single-threaded, you need to write your code in an asynchronous style with either callbacks, Promises or async/await.

DISCLAIMER: The APIs in the following code are fake and were made up for demonstrational purposes. I won’t cover callbacks because they’re are no longer recommended for writing asynchronous JavaScript code in 2019 and I also won’t cover error handling here because we’re talking about concurrency models.

Promises:
function HTTP_DB_FS(resource, fileName) {
return http.Get(resource)
.then(data => db.Query(data))
.then(result => fs.write(fileName, result.data))
}
ES6 async/await:
async function HTTP_DB_FS(resource, fileName) {
const data = await http.Get(resource)
const result = await db.query(data)
return await fs.write(fileName, result.data)
If you block the event loop somewhere you’re going to be in silent trouble because the event loop will easily become your bottleneck! Never block the event loop in JavaScript!

In Go, however, writing blocking code is totally fine since a blocked goroutine is automagically swapped out for another one by the scheduler:
func HTTP_DB_FS(resource, fileName string) fs.File {
data := http.Get(resource)
result := db.Query(data)
return fs.Write(fileName, result.data)
}
The above Go code is almost the same as the JavaScript code before, it’s just non-blocking by nature. As you probably can see, Go’s built-in scheduler makes asynchronous non-blocking code a lot easier to both read and write!

6. Dynamic Execution Environment (JS) vs Compilation (Go)

Since JavaScript is interpreted - your code might be executed in a different environment (such as Node.js and the Browsers), some of which won’t support some features or modules you’ll be using and you have to keep that in mind.

The WebSocket APIs do differ on Node and in Browsers for example. Another great example would be that Node.js uses the Buffer module whereas browsers use the ArrayBuffer primitive. You have to be very careful about what environment capabilities you use and when.

Since Go is statically compiled - everything will be exactly the same on every platform (except some operating system features of course, which you won’t normally use though).

7. Deployment

With Node, you have to make sure the right version of Node is installed on your server. Docker does help, but Go is still one step ahead. A compiled Go binary won’t need anything external to run itself, you just run it.

Final Conclusion

Neither the JavaScript language nor the Node.js runtime was ever designed to be a serious back-end solution.

  • JavaScript was designed as a sandboxable scripting language that could be loaded into the browser, interpreted and then compiled on-the-fly gluing together certain parts of the DOM.
  • Node.js just made it easier for Front-End JavaScript developers to quickly get a micro-service up and running without having to dig into learning back-end languages such as C++, Java etc. (or PHP… though PHP is probably even worse than Node.js in almost all aspects so I won’t consider it a good back-end solution either).
  • JavaScript code can’t fall into synchronization issues and data races because it’s single-threaded by nature. But since Go has channels and a built-in race detector - this advantage becomes rather moderate.

    (but remember that you’re never safe from race conditions, not even in JavaScript because those are semantic issues)

Go was designed to replace C++ for writing networking software at Google (Not to replace C++ in general, many got this claim wrong). It was designed with heavy loads, predictability, maintainability and concurrency in mind.

  • Go was also designed to be a very simple, relatively safe and coherent programming language, which we can’t really say about JavaScript. Most scripting languages including JavaScript tend to be more expressive but harder to read. Go is rather balanced in that regard, it will feel a little more verbose than JS, but it’s also going to be much easier to read and maintain.
  • Go is much faster than Node on average and uses up much less memory (sometimes 10-fold and even more).
  • Go is also non-blocking by nature without requiring you to write asynchronous code.
  • The amazing tooling around Go will help you remove bottlenecks and latency issues relatively easily and make it scale like crazy.
  • Unlike in JavaScript, testing is built-into Go’s standard library and toolchain.
  • With Go, you can write closed-source software, with Node you’d have to obfuscate/uglify your code, which is still much easier to reverse-engineer than compiled Go binaries.

Finally, to me personally, writing server software in JavaScript feels like trying to hammer a nail with a screwdriver, for the reasons described above.

As a professional software engineer, you’ll get productive in Go in less than a month, so don’t waste your time on screwdrivers when you really just need a proper hammer.

If you want to get new posts, make sure to subscribe to Value In Brief by Email.
Comment via Facebook
0 Comment via Google

0 تعليقات:

Post a Comment