← Back to blogBackend

Learning Go as a Node.js developer: what surprised me

August 7, 2024·8 min read

I picked up Go for a specific project: a high-throughput event processing service that was bottlenecked by Node.js's single-threaded execution. The service needed to process 50,000 events per second with CPU-intensive validation on each event. In Node.js, this required a cluster of workers and careful load balancing. In Go, a single binary on modest hardware handled the load.

Learning Go's syntax took a few days. Understanding Go's concurrency model took several weeks. Here's what surprised me coming from years of Node.js.

Goroutines vs async/await

In Node.js, concurrency is cooperative. You use async/await to write code that yields control when waiting for I/O. The event loop manages the scheduling. There's one thread, and everything shares it.

In Go, concurrency is preemptive. Goroutines are lightweight threads managed by the Go runtime. You start a goroutine with the go keyword. The runtime schedules goroutines across OS threads automatically.

// Go: start 1000 concurrent operations
for i := 0; i < 1000; i++ {
    go processEvent(events[i])
}

The mental model shift: in Node.js, you think about "what happens when this I/O completes." In Go, you think about "what happens when these goroutines access the same data."

Node.js doesn't have data races because there's one thread. Go has goroutines running truly in parallel on multiple cores, so two goroutines can read and write the same variable at the same time. This requires explicit synchronization.

// This has a data race
var counter int
for i := 0; i < 1000; i++ {
    go func() {
        counter++ // Multiple goroutines writing simultaneously
    }()
}

// Fixed with a mutex
var mu sync.Mutex
var counter int
for i := 0; i < 1000; i++ {
    go func() {
        mu.Lock()
        counter++
        mu.Unlock()
    }()
}

Go's race detector (go run -race) catches these at runtime. I ran everything with -race during development. It found three data races in my first week that I wouldn't have caught otherwise.

Channels

Channels are Go's primary concurrency primitive. They're typed conduits for passing data between goroutines.

func processEvents(events []Event) []Result {
    results := make(chan Result, len(events))

    for _, event := range events {
        go func(e Event) {
            result := validate(e)
            results <- result  // Send result to channel
        }(event)
    }

    var processed []Result
    for i := 0; i < len(events); i++ {
        processed = append(processed, <-results)  // Receive from channel
    }
    return processed
}

In Node.js, the equivalent pattern uses Promise.all. The difference: channels aren't just for collecting results. They're for communication between concurrent operations. You can use channels to implement fan-out/fan-in patterns, rate limiters, semaphores, and pub/sub systems without any external library.

The closest Node.js equivalent is EventEmitter, but EventEmitter isn't type-safe and doesn't block. Channels block the sending goroutine when the channel is full (buffered channel) or when there's no receiver (unbuffered channel). This blocking behaviour is a coordination mechanism, not a limitation.

Error handling as values

In Node.js:

try {
  const data = await readFile(path);
  const parsed = JSON.parse(data);
  return parsed;
} catch (error) {
  console.error('Failed:', error);
  throw error;
}

In Go:

data, err := os.ReadFile(path)
if err != nil {
    return nil, fmt.Errorf("reading file: %w", err)
}

var parsed Config
if err := json.Unmarshal(data, &parsed); err != nil {
    return nil, fmt.Errorf("parsing config: %w", err)
}
return &parsed, nil

The if err != nil pattern is verbose. I found it annoying for the first week and appreciated it after a month.

The reason: in Node.js, try/catch bundles error handling for a block of code. You often catch errors from multiple operations and handle them generically. In Go, you handle each error at the point where it occurs. This forces you to think about what went wrong and provide a meaningful error message for each failure point.

The result is that Go error messages are typically more informative than Node.js error messages. "reading file: open /etc/config.json: permission denied" tells you exactly what happened. A generic catch block often logs "Error: EACCES: permission denied" without context about which operation failed.

What is genuinely better

Compilation speed. The entire event processing service (about 15,000 lines) compiles in under 2 seconds. go build produces a single static binary with no runtime dependencies. No node_modules, no build step, no bundler configuration.

Deployment simplicity. The binary is a single file. Copy it to the server and run it. No npm install, no Node.js version management, no native module compilation. The Docker image is a scratch image with one file:

FROM scratch
COPY event-processor /event-processor
ENTRYPOINT ["/event-processor"]

The image is 12MB. The equivalent Node.js image (Alpine + node_modules) was 180MB.

CPU utilization. The event processing service uses all available cores by default. In Node.js, using multiple cores requires the cluster module or worker threads, with explicit inter-process communication. In Go, goroutines are scheduled across cores automatically.

Memory efficiency. Go's memory usage for this workload was about 60MB. The Node.js version used about 400MB for the same throughput. The difference comes from V8's memory overhead (JIT compiler, garbage collector metadata) and the per-goroutine stack size (~2KB in Go vs ~1MB per thread in Node.js, though Node.js uses one thread so this is less relevant).

The use case that made Go right

The event processing service receives events from Kafka, validates each event against a set of rules (some involving cryptographic verification), enriches the event with data from a cache, and writes the result to a database.

In Node.js, the cryptographic verification blocked the event loop. Using worker threads added complexity and inter-thread communication overhead. The throughput ceiling was about 8,000 events per second per instance.

In Go, the same validation logic runs in goroutines. Each event gets its own goroutine. The Go runtime schedules them across all available cores. Throughput on the same hardware: 50,000+ events per second.

Go isn't better than Node.js for everything. For web APIs, Node.js's ecosystem, developer experience, and library availability are hard to beat. For CPU-intensive, high-concurrency workloads where performance matters, Go is the better tool.

RESPONSES

Leave a response