This post is part of our introduction series for our technology. Learn the background here or forever wonder how baseball cards, the macarena, and a cloud-native AMS are related.
In the intro to this series, we talked about our big ask. Design an AMS that knows you, connects you, and continuously improves. Anytime. Anywhere. Always.
Brilliant! Okay...now how do we do that? As we talked about in our last installment, we had settled on a serverless microservices architecture. Now it was time to pick a programming language.
However, when choosing the language we would use behind the API our priorities are different. Here our focus is on delivering features to you and to your members quickly and reliably. Although our background includes decades of .NET development, we didn’t want to just default to the comfortable. We wanted to challenge every assumption and learn about the tools helping today's software builders deliver at an unprecedented rate.
Like most people in technology, I’ve heard a lot about Node.js over the last 5-10 years. For me, it started almost like an urban legend. The escaped lunatic who late one night served half a million concurrent connections...with...a single...thread. I’ve spent a lot of time building SaaS platforms with a ton of server-side processing so when I first heard this I was intrigued, a little dubious, and totally not frightened, like at all.
At this point, you might be dubious too (and maybe just a little frightened). The Node.js event-driven architecture made quite a splash in the early 2010s but haven’t most of the alternatives caught up? These days, there are plenty of tests online showing .NET Core 2 outperforming Node.js in certain environments. You can run .NET Core 2 on Linux / macOS. Microsoft’s Kestrel is even using libuv (the library that powers the Node.js event loop) under the covers.
Still, I decided to stray outside the Microsoft ecosystem I have been building on since that baseball card database 30 years ago. Why? It actually has very little to do with the language itself. An interesting thing happens when you pick a serverless microservices architecture. It changes what you value in a programming language. Fundamentally. That completely surprised me. Let me explain.
The first thing to understand is that in the public cloud, a serverless function behaves very similar no matter what language it is written it. From the caller’s perspective, there is no difference between a serverless function written in Java, .NET, Node.js, Python, or any other language. The only real differences are in runtime performance. This includes the two usual suspects, execution time and memory consumption. But serverless functions also have another consideration known as cold-start that only applies the first time a function is executed.
You could think of it like this. Remember that army of helpful little Tamagotchi living in the AWS apartment complex? Well, when “the kids” want to play with one they have to wake it up which takes a second or two. If we don’t use it for a bit, it goes back to sleep.
As summarized in this great blog post by Epsigon, there is little difference between the execution time of different languages once they are warmed up. That’s a little surprising since one of the primary advantages of compiled languages has been runtime performance. But, when you dig a little deeper, it actually makes sense. As pointed out in that blog post, if a serverless function is primarily doing IO (as it would be in good cloud-native design), it will be spending most of its time waiting on network operations. Waiting takes the exact same amount no matter what language you do it in.
Looking at cold start times, we see a different picture. It might not be the one you expect. Most analysis show results that are comparable to this one. Here we see interpreted languages (Python and Node.js) outperforming the compiled ones (.NET and Java) with Python as the clear winner. A bit of a shock, I know. But again, when you dig in it makes sense.
.NET and Java need their VMs to run their first line of code. In a monolithic design, this is no big deal because it usually happens before request execution has started. But in a cloud-native architecture, you have thousands of independent functions that have to do this for every cold-start. Imagine I can wake up my Tamagotchis and start playing immediately while you need to put batteries in yours before you can play with them. One Tamagotchi each with an hour to play, no big deal. This is like a monolithic system. But in a cloud-native design, you may have a thousand Tamagotchis. Or ten thousand. At that scale, I’m going to have a lot more time for fun than you.
Okay, round one is done and we have no decision. On my card, the interpreted languages have the edge with their cold-start advantage. Let's look at how software architecture might impact language choice.
When you’re building systems, you’re always concerned about code-reuse and how a change in one place might cause a problem somewhere else. In a monolithic system, this complexity is usually managed by building with object-oriented principles: abstraction, isolation, inheritance, and polymorphism. Although this can be applied to any language, it is front-and-center in compiled languages like .NET and Java.
In a cloud-native architecture, these principles are still important, but they are achieved in new ways. When you build a microservice, you define exactly how other microservices may interact with it. This may be a specific API or it may fit into a larger event bus. Regardless of the implementation, the microservice designer may do complex tasks when that microservice is invoked - abstraction - and is in control of what data is available to the rest of the system - isolation.
The code in a microservice is deliberately decoupled from all others. However, microservices can import libraries to reference or expand on shared code. While this is not exactly inheritance, it achieves many of the same goals plus the added benefit of being able to version and release independently (which I would argue is far more useful than the benefit of traditional inheritance over libraries). Finally, polymorphism is achieved by signaling one microservice, say one that sends an e-mail, over another, say one that sends SMS, at runtime through a common interface. It seems the nature of the cloud-native design reduces the value of OOP-centric platforms like .NET and Java over interpreted languages. This was unexpected.
Let’s take off the architect hat and put on the developer hat. Now I want to be able to make a change quickly, get it checked-in, tested, and deployed. Interpreted languages are in one corner focusing on making changes fast and easy. This is very, very important if you want to build something as big as an AMS from scratch. In the other corner, a compiler verifies your syntax preventing compile time errors from becoming runtime errors. That is also super important. More important. Maybe so important as to call this round for the compiler - at least before DevOps changed the way we test and deploy.
We’ll dive deeper into that in a later post. But for our purposes here, we know that every single microservice will have multiple layers of tests: unit, integration, and end-to-end. Between a good linter and the unit test suite, we can uncover the majority of the issues a compiler would pick up before check-in.
These days, IDEs such as WebStorm can even run the unit tests for us creating a syntax verification step that works very much like a compiler. In fact, you can take this a step further and use a tool like Wallaby to get instant feedback. Faster even than you could get with a compiler. Again, thanks to the nature of cloud-native architecture we can get the best of both worlds - speed and reliability - out of an interpreted language.
As a side note, the compiler does other things like optimization and code generation as well, but in a world where all languages perform similarly at runtime and code generation is actually a drawback due to cold-start times loading a VM, these things don’t seem very valuable anymore.
At this point we were pretty convinced we wanted to build our AMS entirely on an interpreted language, but which one? As we saw earlier, Python has a slight performance advantage. AWS publishes most of its tools like the CLI in Python and has a history of releasing services, documentation, and SDK updates in Node.js and Python first. We wanted to always be able to take advantage of the latest advances, so we narrowed it down to those two.
The tie-breaker came down to three factors. First was the developer community. We knew we would want people on our team that are passionate about using technology to help you change the world one member at a time. That is our purpose, it’s why we get up in the morning, and we want people around us who feel it just as deeply. So we wanted technology a lot of people are passionate about.
Jeff Atwood once said something that would become known at Atwood’s law:
Between the newest, cloud-native architectures, third-party tooling, and an overwhelmingly large community, this statement seems to be more prescient than ever. That's the ballgame and the winner is easily Node.js.