I recently saw a meme about async and await, and honestly, it hit home.
The idea is that once you use async in your code, it spreads like wildfire. Your function has to be async, then any function calling it has to be async, and this chain just keeps going. It’s like a never-ending loop that climbs all the way to the top of your program. I’ve always found this annoying, but I thought, “Well, there’s no better way to handle it, right? It’s just a necessary evil.”
But then, I started working more with Go. And guess what? Go doesn’t have async or await. This might sound surprising, especially since Go is famous for its concurrency features. But the more I used it, the more I loved it.
Go Doesn’t Need Async/Await
The first time I realized Go didn’t have async/await was when I needed to make my program pause for a second. I was working on my RSS reader app, Stratum, which fetches a ton of RSS feeds. The problem is, if you send too many requests to a website at once, they’ll start rate-limiting you. So, I needed to add a one-second delay between requests.
In Go, it’s super simple:
time.Sleep(1 * time.Second)
That’s it! No await, no fancy keywords—just a straightforward function call. I was curious how this worked without async/await. Did it use some kind of inefficient method like a spin lock? (A spin lock keeps the thread busy in a loop while it waits.) Nope, none of that. Go actually puts the thread to sleep efficiently, without any complicated async syntax. And for me, that was a game-changer.
Async: A Necessary Evil or Overcomplication?
Let’s be real: I’ve never been a fan of async. I think a function should just take some input, do its job, and return a result. That’s it. But async messes with this simplicity. Suddenly, you’re marking functions with special keywords, changing their behavior completely.
Take generators, for example. I first encountered them in Python:
def foo():
yield 1
yield 2
yield 3
It’s a function you can loop through, but it’s not straightforward. Dart has a similar thing:
Iterable<int> simpleGeneratorFun() sync* {
yield 1;
yield 2;
yield 3;
}
If you understand how it works, it’s fine. But if you don’t? It’s just chaos. And async functions feel the same way to me—unnecessarily confusing.
Async and Performance: Not Always Better
Some might argue that async/await improves performance. But does it? Not always. Most async functions are used for things like network requests, which makes sense. But the truth is, there are simpler ways to run tasks asynchronously. For example, in Go, you can just do this:
go myFunction()
It’s multithreading, plain and simple. I get it—multithreading can be tricky and prone to bugs. But async/await doesn’t fix those bugs. To really handle concurrency well, you need separate memory spaces, like Dart’s isolates. Async/await just adds more complexity without solving the root problem.
Async/await Can Be Slower and Riskier
Another issue with async/await is that it’s not always faster. In fact, it can slow things down because the task scheduler gets involved. And here’s the kicker: it’s super easy to accidentally call an async function thinking it’s synchronous. This can cause major issues, from crashes to incorrect results.
Dart even has a lint rule, avoid_slow_async_io, that tells you to use synchronous methods like Directory.existsSync instead of their async counterparts. If the synchronous version is faster, why do the async ones exist in the first place? It’s often because someone decided, “Hey, this requires a system call, so let’s make it async.” But not every system call needs to be awaited. Imagine if you had to await every time you checked the current time. That would be a nightmare.
The Viral Nature of Async/Await
Async/await isn’t just a technical issue—it’s an ideological one. It started with Microsoft’s C# (or technically F#) and quickly spread to other languages like JavaScript, Python, and Dart. But this viral adoption doesn’t mean it’s the best solution. It’s like a single async function infects your entire codebase, forcing you to rewrite everything to fit its model.
And let’s be honest: forcing every function to be async is overkill. Yes, running code asynchronously is useful, but async/await has taken it too far. It complicates code unnecessarily and makes debugging a pain.
Go Got It Right
The more I work with Go, the more I think they got it right. Instead of async/await, Go uses goroutines, which are lightweight threads. They let you run tasks concurrently without marking functions as async. This approach keeps your code clean and simple while still being highly efficient.
Final Thoughts
Async/await might seem like the perfect solution for handling asynchronous code, but it’s not without its flaws. It complicates your codebase, introduces new types of bugs, and often doesn’t deliver the performance benefits you’d expect. Meanwhile, languages like Go prove that there are better, simpler ways to handle concurrency.
Callback Hell and Beyond
One commenter mentioned that the primary reason async/await was introduced was to move past “callback hell,” a problem that made early asynchronous programming difficult to manage.
While async/await succeeded in simplifying code readability in this regard, others argue that there are better alternatives, such as using plain futures or promises with a functional API. These approaches can maintain clarity without introducing the viral nature of async functions.
Event Loops vs. Threads
Another valuable perspective compared event-based pseudo-parallelism (like JavaScript’s event loop) with true multi-threaded parallelism. While event loops avoid blocking the main thread, they can make code more complex to reason about. Some suggest that Go’s model, which abstracts these details using goroutines, offers a more intuitive way to handle concurrency.
Debugging and Developer Experience
A recurring theme was the challenge async/await poses for debugging. Unlike synchronous code, where errors follow a straightforward flow, async code jumps between tasks, making it harder to trace issues. One commenter noted that tools like RxJS provide a better debugging experience by allowing developers to observe the entire asynchronous flow until completion.
Performance and Overhead
Several comments discussed the performance implications of async/await. While async/await reduces blocking, it introduces overhead due to the need for task scheduling. Others noted that async functions can sometimes degrade performance, especially in scenarios where synchronous alternatives (e.g., synchronous file I/O) are faster and more efficient.
Structured Concurrency
Some commenters pointed to “structured concurrency” as an emerging concept that aims to provide better control over asynchronous flows without the downsides of async/await. Structured concurrency frameworks, like Kotlin’s Coroutines, ensure that concurrent tasks are scoped, making them easier to manage and debug.
Real-Life Use Cases
A few shared practical experiences, highlighting when async/await works well. For instance, one developer praised async/await for transforming a sequential state machine into a single, easy-to-debug routine. Another emphasized its utility in web development and distributed systems, where managing asynchronous workflows is crucial.
Criticisms and Alternatives
Critics argue that async/await’s biggest flaw is its “viral” nature, which forces entire codebases to adapt to a single asynchronous dependency. Suggestions for alternatives included Go’s goroutines, Rust’s async model, or even sticking to multi-threading for specific use cases.
So maybe it’s time to rethink async/await. It’s not the holy grail of programming—it’s just one approach among many. And sometimes, simpler is better.