Skip to main content

Command Palette

Search for a command to run...

Single Thread vs Goroutines: The Real Difference Between Node.js and Go

Updated
7 min read
Single Thread vs Goroutines: The Real Difference Between Node.js and Go
A
Hi, I'm Adegoke Ayobami Seun, a software developer.

You've probably heard it a thousand times: "Node.js is single-threaded" and "Go has goroutines." But what does that actually mean when you're building real applications?

Here's the thing both Node.js and Go can handle thousands of simultaneous connections. Both can build blazingly fast APIs. Both power some of the world's biggest companies. Yet they do it in completely opposite ways.

Node.js is like a brilliant multitasker who can juggle ten conversations at once—but it's still one person doing all the work. Go is like having a team where everyone handles their own tasks independently. Same result, wildly different approach.

This isn't about which is "better." It's about understanding what's actually happening under the hood so you can choose the right tool for your problem. Let's break it down without the jargon.

Node.js: One Thread, Infinite Patience

Node.js runs everything on a single thread using something called the event loop. Before your eyes glaze over, here's what that actually looks like:

// Node.js - Everything runs on one thread
console.log("Start");

setTimeout(() => {
  console.log("After 2 seconds");
}, 2000);

console.log("End");

// Output:
// Start
// End
// After 2 seconds

Node doesn't wait for the timer. It says "I'll come back to this" and moves on. When 2 seconds pass, the callback gets added to the to-do list.

The Go Approach: Goroutines

Go creates lightweight threads (goroutines) that can truly run at the same time:

// Go - Can use multiple cores
package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println("Start")
    
    go func() {
        time.Sleep(2 * time.Second)
        fmt.Println("After 2 seconds")
    }()
    
    fmt.Println("End")
    time.Sleep(3 * time.Second)
}

// Output:
// Start
// End
// After 2 seconds

The goroutine runs independently. If you have 4 CPU cores, Go can actually use all 4 at once.

Real Example: Fetching Multiple URLs

Let's see both languages tackle the same problem.

// Using async/await (modern Node.js)
const fetch = require('node-fetch');

async function checkWebsite(url) {
    console.log(`Checking ${url}...`);
    const response = await fetch(url);
    console.log(`\({url} status: \){response.status}`);
}

async function main() {
    const websites = [
        'https://google.com',
        'https://github.com',
        'https://stackoverflow.com'
    ];
    
    // Sequential - one at a time (slow)
    for (const url of websites) {
        await checkWebsite(url);
    }
    
    console.log("Done!");
}

main();

This checks websites one by one. To check them all at once:

async function main() {
    const websites = [
        'https://google.com',
        'https://github.com',
        'https://stackoverflow.com'
    ];
    
    // Parallel - all at once (fast)
    await Promise.all(
        websites.map(url => checkWebsite(url))
    );
    
    console.log("Done!");
}

Go Version

package main

import (
    "fmt"
    "net/http"
    "sync"
)

func checkWebsite(url string, wg *sync.WaitGroup) {
    defer wg.Done()
    
    fmt.Printf("Checking %s...\n", url)
    resp, err := http.Get(url)
    if err != nil {
        fmt.Printf("%s error: %v\n", url, err)
        return
    }
    fmt.Printf("%s status: %d\n", url, resp.StatusCode)
}

func main() {
    websites := []string{
        "https://google.com",
        "https://github.com",
        "https://stackoverflow.com",
    }
    
    var wg sync.WaitGroup
    
    for _, url := range websites {
        wg.Add(1)
        go checkWebsite(url, &wg)
    }
    
    wg.Wait()
    fmt.Println("Done!")
}

The Big Difference: True Parallelism vs Concurrency

Node.js: One thing at a time, but switches so fast it feels simultaneous. While waiting for I/O (files, network, database), it does other work. This is called concurrency.

Go: Multiple things truly happening at the same time on different CPU cores. This is called parallelism.

Think of it this way:

  • Node.js = One chef rapidly switching between multiple pans

  • Go = Multiple chefs, each with their own stove

Handling Results: Promises vs Channels

Node.js: Promises

function fetchUserData(userId) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve({ id: userId, name: "John" });
        }, 1000);
    });
}

async function main() {
    try {
        const user = await fetchUserData(123);
        console.log(user);
    } catch (error) {
        console.error(error);
    }
}

Promises are like IOUs. You get a promise immediately, and later it either delivers the value or an error.

Go: Channels

package main

import (
    "fmt"
    "time"
)

type User struct {
    ID   int
    Name string
}

func fetchUserData(userId int, result chan User) {
    time.Sleep(1 * time.Second)
    result <- User{ID: userId, Name: "John"}
}

func main() {
    userChan := make(chan User)
    
    go fetchUserData(123, userChan)
    
    user := <-userChan 
    fmt.Println(user)
}

Channels are like pipes. One goroutine puts data in, another takes it out.

Performance Comparison

Image Processing (Resizing Photos)

Node.js — CPU work blocks the server

// Simulate heavy image processing
function resizeImage() {
  const start = Date.now();

  // Fake CPU-heavy loop (like resizing a huge image)
  while (Date.now() - start < 3000) {}

  console.log("Image resized");
}

console.log("Start");

resizeImage();   // Blocks everything for 3 seconds

console.log("End");

Output (after 3 seconds):

Start
Image resized
End

Go — CPU work can run in parallel

package main

import (
    "fmt"
    "time"
)

func resizeImage() {
    start := time.Now()

    // Fake CPU-heavy loop
    for time.Since(start) < 3*time.Second {
    }

    fmt.Println("Image resized")
}

func main() {
    fmt.Println("Start")

    go resizeImage() // Runs on another goroutine / core

    fmt.Println("End")

    time.Sleep(4 * time.Second) // wait so program doesn't exit
}

Output (instant):

Start
End
Image resized

The program keeps running while the heavy task happens in parallel.

Winner: Go. CPU-intensive work can use multiple cores.

I/O-Heavy Task (Database queries, API calls)

Both handle this well! Node.js's single-threaded model is actually perfect for I/O because there's no waiting anyway.

Winner: Tie. Both are excellent for I/O operations.

Memory Usage

Node.js: Each pending operation uses minimal memory (just a callback).

Go: Each goroutine uses about 2KB (tiny, but more than a callback).

For 10,000 simultaneous operations:

  • Node.js: ~10MB

  • Go: ~20MB

Both are efficient, but Node has a slight edge here.

Error Handling

Node.js

try {
    await riskyOperation();
} catch (error) {
    console.error("Something broke:", error);
}

Errors bubble up naturally through promises and async/await.

Go

result, err := riskyOperation()
if err != nil {
    fmt.Println("Something broke:", err)
    return
}

Go makes you handle errors explicitly. No silent failures.

Which Should You Choose?

Choose Node.js when:

  • Building web APIs and microservices

  • Your work is mostly I/O (databases, file systems, networks)

  • You want a huge ecosystem (npm)

  • Your team knows JavaScript

  • You need rapid development

Choose Go when:

  • You have CPU-intensive work

  • You need maximum performance

  • You want simpler deployment (single binary)

  • You're building system tools or DevOps utilities

  • You need true parallelism across multiple cores

The Real-World Truth

Both are fantastic for web servers handling thousands of requests:

Node.js powers: Netflix, LinkedIn, PayPal, Uber Go powers: Docker, Kubernetes, Dropbox, Uber (yes, both!)

The difference matters most when you're doing heavy computation or need every ounce of performance.

Code Comparison: Same Task, Different Approaches

Processing 1000 Items

Node.js:

async function processItems(items) {
    // Process in batches to avoid overwhelming the system
    const batchSize = 10;
    for (let i = 0; i < items.length; i += batchSize) {
        const batch = items.slice(i, i + batchSize);
        await Promise.all(batch.map(processOne));
    }
}

Go:

func processItems(items []Item) {
    var wg sync.WaitGroup
    
    // Process all at once (goroutines are lightweight)
    for _, item := range items {
        wg.Add(1)
        go func(item Item) {
            defer wg.Done()
            processOne(item)
        }(item)
    }
    
    wg.Wait()
}

Go can handle thousands of goroutines easily. Node needs batching to prevent memory issues.

Final Thoughts

Node.js is like a master juggler one person keeping many balls in the air through speed, timing, and efficiency.
Go is like a team of jugglers each working independently, sharing the load, and performing in true parallel.

Neither is universally “better.” They’re designed for different kinds of problems.
Node.js shines in I/O-heavy systems where responsiveness and developer velocity matter most.
Go excels when performance, scalability, and parallel execution are critical.

The right choice isn’t about trends or hype it’s about understanding your workload.
Pick the tool that matches your problem, and either one can power a fast, reliable system.

More from this blog

A Comprehensive Comparison of Next.js and React.js Which Framework is Better Suited for Your Project, Next.js vs. React.js Differences

12 posts

Autodidat 🖲️ Dedicated to lifelong learning and self-improvement. 📡

Go vs Node.js: Concurrency, and Backend Trade-off