Recently, I have been exploring Go and JavaScript TypeScript. Coming from the Python world, parallel or asynchronous code was a completely foreign concept. Sure, there are the asyncio and multiprocessing packages in the standard library, but I’ve never had a good reason to actually use them. Working in the data space with Python typically meant that I never needed to worry about any of these concepts. They are just magically handled by a library (e.g. Pandas, Spark, etc.). So what is asynchronous code? What is parallel code? Are they actually meaningfully different?

Asynchronicity

To state the obvious, asynchronous simply means that events or processes do not occur synchronously (i.e. at the same time). This definition sounds quite similar to parallelism, but the underlying mechanisms are actually very different, and we’ll soon see why.

Asynchronous code is extremely common in web development. It allows the client (i.e. user) to send a request to a server without the whole webpage freezing up while waiting for a response. Needless to say, synchronous code running on websites would be a pretty terrible user experience.

Like Python, JavaScript/TypeScript is single-threaded. Only one thing can happen at a time.

A Helpful Async Analogy

Let’s imagine a real-world example of asynchronicity. You call a local restaurant to place an order, but rather than waiting on hold until it’s ready, you leave them your phone number and ask them to call you back when it’s done. In programming, that phone number is a callback, which is just a function you hand over ahead of time, to be run later when the thing you’re waiting on completes.

Since the restaurant has a way to call you back, you hang up and get on with your day. You might do some work, clean your room, run errands; basically do anything but stand by the phone. When the restaurant calls back, you don’t need to answer the phone right away. They can leave a voicemail with a message informing you the food is ready. You can check your voicemail when you are available and handle picking up the food accordingly. You never did two things at once; you just didn’t let the waiting eat up your time. This is async work. You can still only do one thing, but you rely on the services of others to help you get more done at once.

So in this analogy, you are a single thread playing two roles. Most of the time you’re just going about your day working through the errands you can do right now. But some errands get stuck. The one that needs the restaurant’s food can’t move forward until they call you back. When you hit a moment like that, you await it by setting that errand aside. And instead of standing idle, you move on to the next errand on your list. The await keyword in JavaScript and Python is basically like saying “I can’t continue past this point until the result arrives, so suspend the task here and go do something else in the meantime.”

While you’re suspended on one errand, you’re still checking in on the others. This is where you act as the event loop. Whenever you finish a sub-task, you glance at your phone to see if the restaurant has called back (checking the event queue). If there’s a voicemail waiting, you pick up the now-pending errand right where you left it. You’re never really doing two things at once, just switching between errands as each one becomes ready to move forward.

This is the same idea whether you’re in Python or JavaScript. Python’s asyncio exposes the loop explicitly — you start it with asyncio.run(...) and pause work with await. JavaScript hides it behind the language itself, but the model is identical: there’s a call stack, a queue of callback functions, and a loop that processes the queue whenever the main thread is not doing any work.

So asynchronous code lets you wait on many things at once. But it’s still just you, all alone, juggling between all those tasks.

Parallelism, on the other hand, is a conceptually distinct. Instead of needing to rely on other services, you can build up a team of people to work together. You control what each person does. Obviously, this is more powerful, but you also have more complexity since you now need to figure out how to organize these people to work and communicate together. If done poorly, you might have just been better off interacting with another service. But if it is something very custom that you need to control, this is an excellent way to go.

Examples of Async Code

Let’s first take a look at some async Python code. Below is a program that simulates a network call to fetch some data about some users. We define the fetch_data function to be asynchronous and await it in main function with the asyncio.gather method.

import asyncio


async def fetch_data(name: str, delay: float) -> str:
    print(f"Fetching data for {name}...")
    await asyncio.sleep(delay)
    print(f"Data for {name} received!")
    return f"{name} data"


async def main() -> tuple[str, str, str]:
    results = await asyncio.gather(
        fetch_data("user1", 2),
        fetch_data("user2", 1),
        fetch_data("user3", 3),
    )

    print(f"All results: {results}")
    return results


asyncio.run(main())

The above program returns the following output. Notice that although the request for user1’s data was sent before the request for user2’s data, the data for user2 is received first. This is because we set the delay for user2 as only one second, so it is returned first while we wait for data from the other users.

Fetching data for user1...
Fetching data for user2...
Fetching data for user3...
Data for user2 received!
Data for user1 received!
Data for user3 received!
All results: ['user1 data', 'user2 data', 'user3 data']

Now let’s look at the same program again, but this time written in TypeScript.

async function fetchData(name: string, delaySeconds: number): Promise<string> {
  console.log(`Fetching data for ${name}...`);
  await new Promise(resolve => setTimeout(resolve, delaySeconds * 1000));
  console.log(`Data for ${name} received!`);
  return `${name} data`;
}

async function main(): Promise<string[]> {
  const results = await Promise.all([
    fetchData("user1", 2),
    fetchData("user2", 1),
    fetchData("user3", 3)
  ]);
  
  console.log(`All results: ${results}`);
  return results;
}

main().catch(error => console.error('Error:', error));

The structure maps almost one-to-one onto the Python version: async function is async def and Promise.all([...]) is asyncio.gather(...). Both kick off the tasks and wait for all of them to finish. setTimeout stands in for asyncio.sleep as our simulated delay.

The output is the same as well. user2 still completes first, since its delay is shortest:

Fetching data for user1...
Fetching data for user2...
Fetching data for user3...
Data for user2 received!
Data for user1 received!
Data for user3 received!
All results: [ 'user1 data', 'user2 data', 'user3 data' ]

Both versions do the same work, the same way, on a single thread. So what does parallel code look like?

Parallelism

Before we jump into looking at some parallel code, let’s take a look at how parallelism differs from asynchronicity at a conceptual level.

A Helpful Parallel Analogy

Instead of outsourcing your meals to a restaurant, imagine you hire a few cooks to work in your own kitchen. You’re no longer just placing an order and waiting. You’re now the head chef, and you decide what each cook does, when they start, and how they hand their dishes off to you.

This gives you a lot of control, but it also introduces responsibilities you didn’t have before. Each cook now needs to be told what to do, and you need somewhere the finished dishes can go so they don’t pile up in the each of the chefs’ own workspace and block them from cooking new dishes.

But there’s also a more subtle problem to address here. Without some kind of system in place, two cooks might reach for the same spot on the counter at the same time, perhaps for the same ingredient, the same burner, or the same cutting board. In programming, this is known as a race condition, where two concurrent threads try to access or modify the same piece of memory at the same time. These issues can be very tricky to debug, because they often silently corrupt data in your program rather than crashing loudly.

In Go, both of these problems are solved by the same two mechanisms: goroutines and channels. A goroutine is a function launched to run concurrently, like a cook doing work independently in your kitchen. A channel is a conveyor belt running between the cooks: when a goroutine finishes a unit of work, it places the result onto the channel, freeing itself up to start the next item. If the channel fills up, the goroutine is blocked from doing more work until space frees up.

That same conveyor belt also sidesteps the race condition. Instead of two cooks reaching for the same spot on a shared countertop, each cook puts their processed output into the channel and pulls results out of it — they never touch the same piece of memory directly. As Effective Go puts it:

Do not communicate by sharing memory; instead, share memory by communicating.

By passing data over channels, threads share information by communicating rather than by accessing a shared memory address. Channels avoid this particular kind of race condition. Go also provides a sync.Mutex type for cases where channels aren’t the right fit, but channels are the default you’ll usually reach for first.

So going back to managing our kitchen, we can have a few chefs preparing raw ingredients; one cuts the raw vegetables while another de-bones the chicken. Both put their end result onto a conveyor belt for another chef to cook into a finished dish.

Between async and parallelism, there is a key shift in the mental model. In async code, everyone is sharing one cook (you, juggling between different task). In parallel code, you have many cooks, and the hard part becomes figuring out how they communicate.

An Example of Parallel Code

Let’s revisit the same scenario as before: fetch data for user1, user2, and user3 with simulated network delays of 2, 1, and 3 seconds. This time, though, we’ll write it in Go using goroutines and channels.

Before the code, a quick primer for anyone (like me, not long ago) who’s never seen Go:

  • Writing go fetchData(...) launches fetchData as a lightweight concurrent worker, called a goroutine, instead of calling it inline. The function starts running at the same time as the rest of main continues.
  • make(chan string) creates a channel — a typed pipe that carries string values. You send a value in with ch <- x and pull one out with <-ch.
  • Goroutines don’t share variables to talk to each other. Instead, they pass values over channels.
package main

import (
    "fmt"
    "time"
)

func fetchData(name string, delay int, ch chan<- string) {
    fmt.Printf("Fetching data for %s...\n", name)
    time.Sleep(time.Duration(delay) * time.Second)
    fmt.Printf("Data for %s received!\n", name)
    ch <- fmt.Sprintf("%s data", name)
}

func main() {
    ch := make(chan string, 3) // buffered for 3 results

    go fetchData("user1", 2, ch)
    go fetchData("user2", 1, ch)
    go fetchData("user3", 3, ch)

    // Collect one result per goroutine, in the order they finish
    var results []string
    for i := 0; i < 3; i++ {
        results = append(results, <-ch)
    }
    fmt.Printf("All results: %v\n", results)
}

This code produces the following output

Fetching data for user1...
Fetching data for user2...
Fetching data for user3...
Data for user2 received!
Data for user1 received!
Data for user3 received!
All results: [user2 data, user1 data, user3 data]

This looks pretty much identical to the async output earlier — and user2 still finishes first, just like before. But the mechanism underneath is quite different. The goroutines can run in parallel on multiple OS threads, whereas the async examples were cooperatively sharing a single thread. Since both examples are simulating network requests (I/O work), the real advantage of parallelism is not so pronounced here. If each goroutine was instead processing some data with the CPU, we would be doing something that neither Python nor JavaScript to do asynchronously.

There’s one more subtle difference worth pointing out. In the Python and TypeScript examples, asyncio.gather and Promise.all give you results back in the order they were submitted. In the Go example, we read results off the channel in completion order, so results ends up as [user2 data, user1 data, user3 data]. This is a direct consequence of how channels work. You receive whatever value lands first. If you wanted maintain submission order in Go, you would have to send the index alongside each result and sort when all goroutines are complete. However, I’d argue the completion order is usually what you want most of the time when you’re waiting on independent tasks.

When to use which?

So, are async and parallelism meaningfully different? Yes, but in reality only if you’re work is I/O bound.

Async code shines when your program is mostly waiting on something else, like network calls, disk reads, or database queries. You’re not doing much work yourself; you just need to not block while you wait. A single thread and an event loop are enough to handle thousands of slow clients at once, which is why async is the default in web servers and I/O-heavy code.

Parallelism shines when you have independent sub-tasks you can split up and coordinate, such as CPU-bound work, pipelines, or fan-out/fan-in jobs. You get real parallelism across multiple cores, at the cost of thinking about how the workers communicate and stay in sync. For I/O bound work, your parallel workers provide similar performance as asynchronous tasks, just with a different underlying mechanism.

Async and parallelism are both forms of concurrency, but they are geared towards different types of work. Async basically means “don’t block, schedule a follow up, and keep doing something else in the meantime”. Parallelism means “orchestrate work between your own workers”. Neither is strictly better. They’re just tools for solving different shapes of the same underlying problem: doing more than one thing at a time.