Post

Battle of the Backends - 1

Preface

I once and for all wanna settle up this dispute. The dispute which has been bugging me ever since I’ve started exploring about backend servers. A question always arises in my brain, which tech stack should I use for my backend? Should I just stick with conventional Java + SpringBoot stack? or be more progressive and accept the newer technologies?


I’m gonna be honest, I never liked Java as a programming language. But don’t get me wrong, the concept of Java is quite amazing. If you’ve ever read Oracle’s Java documentation, it beautifully explains the core philosophy of Java; that is Object Oriented Programming. To quote a few paragraphs from the guide.

Objects are key to understanding object-oriented technology. Look around right now and you’ll find many examples of real-world objects: your dog, your desk, your television set, your bicycle.

Real-world objects share two characteristics: They all have state and behavior. Dogs have state (name, color, breed, hungry) and behavior (barking, fetching, wagging tail).

Software objects are conceptually similar to real-world objects: they too consist of state and related behavior. An object stores its state in fields (variables in some programming languages) and exposes its behavior through methods (functions in some programming languages). Methods operate on an object’s internal state and serve as the primary mechanism for object-to-object communication. Hiding internal state and requiring all interaction to be performed through an object’s methods is known as data encapsulation — a fundamental principle of object-oriented programming.

- What is an Object? (Java documentation)

So, why not just stick to Java and call it a day. Why worry about all the new technologies or advancements if you can get the work done?

This is where we fall in the pit. Java released in 1996, almost 28 years as of 2024. In 1996, the maximum amount of RAM a PC could hold is around 4 GB1, right now, with the advent of 64-bit processors the capacity has exponentially increased to 4PB2.

I would proclaim that the solutions built 3 decades ago aren’t viable for problems that are present right now. So, to use a older paradigm just because everyone is using it, is just like living in a delusion that an AI won’t take over your job!

Benching languages

Now, enough of philosophy. Let’s get our hands on the numbers. The numbers which decide the applicability of the current backend technologies. For the comparision, I’ve taken these four tech stacks. The factors that made me choose these are quite biased, but hear me out.

  1. SpringBoot + Java: I hate to say this, but a plethora of backend servers are written in this language. And after giving a long ass speech about the philosophy of Java, I couldn’t ignore this one.
  2. ExpressJs + Node: If it weren’t to be Java, its always Node. Not that I completely hate it, but I don’t like it either. Nevertheless, it’s quite ubiquitous.
  3. Flask + Python: This is one framework every python developer knows or heard of. It’s easy to learn and quite straightforward to deploy.
  4. Hono + Bun: Bun has been in quite the popularity now. It’s super-fast runtime is one of the main reason I wanted to do this benchmark.

But what about the other languages? Yes, languages like Golang, Rust are definitely an option. I’m just too time contrained to learn and deploy these languages. So, I put them on hold as of now.

How to test them?

The initial problem was how should I test them?
The solution I came up with was to simulate some kind of a login mechanism. Since, many big servers have to handle thousands of logins at some instance of time, and if we were to bench that kind of implementation, we could reach to some kind of conclusion with the working of the server on this heavy threshold. The Skeleton of the plan goes like this

Wait. This has no corelation with the logic of logging in a user?!
If this is the query, then here is my answer. The first step, getting random bytes is analogous to retriving a hashed password from the server. The second step of generating a hash is not intuitive w.r.t to first step, but when we are logging in a user, we try to generate a hash with the user input. This step is quite similar to that. And the step of comparing two hashes, is something I’ve not implemented. Since it’s an O(k) operation for comparing two hashes. I’ve left it out of this logic.

Implementation specifics

Now, let’s look into how I’ve implemented the logic in these languages. If you want to just look at the entier code, I’ve uploaded them into this repo

SpringBoot

1
2
3
4
5
6
7
8
9
10
11
    @GetMapping("/")
    public String index() throws NoSuchAlgorithmException {
        Random random = new Random();
        byte[] arr = new byte[100];

        random.nextBytes(arr);

        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] encodedHash = digest.digest(arr);
        return bytesToHex(encodedHash);
    }
  • The bytesToHex function is a custom implementation taken from Baeldung
  • I didn’t wanna add extra exception handling, so I just let the program throw NoSuchAlgorithmException for MessageDigest.getInstance

Flask

1
2
3
4
@app.get("/")
def index():
    bytes = random.randbytes(100)
    return hashlib.sha256(bytes).hexdigest()

Simplicity at its best

Hono

1
2
3
4
5
6
7
8
const app = new Hono()

app.get('/', (c) => {
  const buf = crypto.randomBytes(100)
  const hash = crypto.createHash('sha256').update(buf).digest('hex')
  c.status(200)
  return c.text(hash)
})

ExpressJs

1
2
3
4
5
6
7
app.get("/", (req, res) => {
  const buf = crypto.randomBytes(100)

  const hash = crypto.createHash('sha256').update(buf).digest('hex')

  res.send(hash)
})

Since Bun and Node running the same language, the code would be similar.

Deployment

To keep the evaluation fair, all of the applications have been deployed to AWS Elastic Beanstalk with the following Specifications.

InstancevCPUMemory(GiB)Network Performance (Gbps)StorageProcessor
t3.micro21Upto 4EBS-onlyIntel Xeon Scalable processor

These are directly taken from AWS website

Nodejs and Flask were an easy deployment. SpringBoot initially failed to work, but after configuring the default port, it started working like a charm. And Hono, for some reason AWS hated it. Even after depolying it via docker, it just refused to run it. At the end for hono, I had to manually SSH into the instance and rebuild the docker file to make it run.

The Server Slayer!

To test these deployed server, I had to put them on intense work load. For this job, I’ve chosen Golang! This had two reason:

  1. To understand the power of GoRoutines
  2. To hit the deployed servers fast enough to break them

The implementation of Server Slayer is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {

  if len(os.Args) < 2 {
    fmt.Printf("Usage: %s URL\n", os.Args[0])
    os.Exit(0)
  }

  var wg sync.WaitGroup

  url := os.Args[1]
  for i := 0; i < 100000; i++ {
    wg.Add(1)
    go makeHTTPRequest(url, i, &wg)

    if i % 1000 == 0 {
      time.Sleep(time.Second * 1)
    }
  }

  wg.Wait()
  fmt.Println("Finished!")
}

The working of the main function is quite straightforward; It takes the URL argument from the command line, spawns a go routine of makeHttpRequest function for a 100k times and waits for all the requests to finish at the end. The extra of code of sleeping for 1 seconds for every 1000th request was to give the Operating System some breathing time to ensure all the GoRoutines are instantiated.

If you want to have a look at makeHTTPRequest function, feel free to check it here.

Results

Time for the long awaited results. All of the servers were tested for 100k requests and the given graphs below are their metrics.

  • To be honest, I’m not shocked. But, definitely impressed by Hono + Bun. In the overall performance canvas, Bun outdoes the rest of them. But, nonetheless it took me a long time to deploy it on Beanstalk, which is one of it’s major downfall.
  • On the other hand, I was taken back with the poor performance of python. I knew python was slow, but to perform this poorly was something I wasn’t expecting. I expected that the problem was on my side and tried optimizing the gunicorn configuration running Flask, but it was futile. The best configuration barely got it down to 19k errors.
  • SpringBoot took the most time, but it produced the least amount of errors. From what I can infer, this is the reason people prefer older and stable languages. Well, at the end of the day, it’s your own preference.
  • ExpressJs performance quite good aswell, even thought the average CPU utilization was on the higher side, it did handle well on heavy pressure.

That’s the end of the chapter for testing. The main question I ask myself here is, did I find the answer. The question of the best backend server?

The answer isn’t that simple. But, I give my conclusion as tree.

Postscript

The decision making of this article is still premature. Languages like Golang, Rust, Frameworks like Starlette, Sanic, NuxtJs are formidable competitors in this field. This is just the start of my exploration in backend servers. My endeavors will continue until I find the one which satiates me. Until then, goodbye for now stranger!

References

This post is licensed under CC BY 4.0 by the author.