Go oder Node.js? Eine Architekturentscheidung

Cyber Panda als Psychologe zwischen zwei Laufzeiten Die Architekturentscheidung: Go oder Node.js für den neuen Service?

Einleitung

Du startest einen neuen Service und die erste Frage nach dem Framework ist schnell beantwortet — aber die Frage nach der Laufzeit bleibt: Go oder Node.js? Ich musste diese Entscheidung kürzlich für ein Team treffen, das eine JSON-API mit hohem Durchsatz und geringer Latenz bauen will. Die Antwort klingt nach Glaubenskrieg, ist aber oft eine Frage nach konkreten Betriebsanforderungen.

Kontext

Es geht um eine API, die pro Sekunde mehrere hundert Requests verarbeiten soll, mit gelegentlichen Spitzen bei Kampagnen. Das Team besteht aus drei Entwicklern, die sowohl mit TypeScript als auch mit Go vertraut sind, aber keine langjährige Erfahrung mit Performance-Tuning oder Memory-Profiling haben. Was auf dem Spiel steht: Durchsatz, Speicherverbrauch, Startzeit der Container und die Fähigkeit, Fehler schnell zu reproduzieren. Non-Goals: Maximale CPU-Auslastung bei einer Million Requests pro Sekunde, komplexe Plugin-Systeme oder native GUI-Bindings.

Option A: Go

Go kompiliert zu einer einzigen statischen Binärdatei, bringmt einen kleinen Runtime-Footprint mit und ist auf Concurrency by Design ausgelegt.

Pros:

  • Geringer Speicherverbrauch und schnelle Startzeit — ideal für Container und Serverless
  • Goroutinen machen concurrenten Code lesbarer als Thread-Management in anderen Sprachen
  • Tooling ist minimal: go build, go test, go vet — keine zusätzliche Laufzeit nötig

Cons:

  • Fehlerbehandlung ist explizit und repetitiv — wer if err != nil hasst, wird unglücklich
  • Ökosystem ist kleiner als bei Node.js, besonders bei spezialisierten SaaS-SDKs
  • Generics sind neu und noch nicht überall konsistent eingesetzt
// Beispiel: Einfacher HTTP-Endpunkt in Go
package main

import (
    "encoding/json"
    "net/http"
)

type Order struct {
    ID      string  `json:"id"`
    Amount  float64 `json:"amount"`
    Product string  `json:"product"`
}

func createOrder(w http.ResponseWriter, r *http.Request) {
    var o Order
    if err := json.NewDecoder(r.Body).Decode(&o); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "status": "created",
        "id":     o.ID,
    })
}

Option B: Node.js

Node.js läuft auf der V8-Engine, hat ein riesiges Ökosystem und ist für viele Teams der Default, wenn sie bereits TypeScript beherrschen.

Pros:

  • Ökosystem ist riesig — fast jedes Problem hat bereits ein npm-Paket
  • TypeScript-Integration ist erstklassig und im Alltag etabliert
  • Debugging und Hot-Reloading sind in IDEs und CLI-Tools gut supported

Cons:

  • Single-Threaded-Modell: CPU-intensive Arbeit blockiert den Event-Loop
  • Memory-Verbrauch pro Container ist höher — bei vielen Instanzen addiert sich das
  • Performance-Tuning erfordert Erfahrung mit Event-Loop-Latenzen und Worker-Threads
// Beispiel: Einfacher HTTP-Endpunkt in Node.js mit Express
const express = require('express');
const app = express();

app.use(express.json());

app.post('/orders', (req, res) => {
    const { id, amount, product } = req.body;

    if (!id || typeof amount !== 'number') {
        return res.status(400).json({ error: 'Invalid payload' });
    }

    res.json({ status: 'created', id });
});

app.listen(3000, () => console.log('API listening on :3000'));

Entscheidung

Ich habe mich für Go entschieden — zumindest für diesen Service.

Die Begründung ist betriebsorientiert: Wir erwarten, dass die Container knapper bemessen sein müssen und dass die Startzeit bei Deployments eine Rolle spielt. Go kompiliert zu einer Binärdatei, die ohne zusätzliche Laufzeit auskommt, und der Speicherverbrauch pro Instanz ist deutlich geringer als bei Node.js. Das macht den Umgang mit Autoscaling und Replica-Zahlen einfacher.

Der Trade-off: Das Team muss sich mit Go-Idiomen anfreunden und wird im Vergleich zu Node.js häufiger Boilerplate schreiben. Ich akzeptiere das zugunsten der besseren Vorhersagbarkeit im Betrieb und der einfacheren Fehleranalyse, wenn ein Container im Lasttest Probleme macht.

Implementierung

Der Einstieg ist ein einzelnes Binary ohne Dependency-Chaos:

# Initialisierung und Build
mkdir -p svc && cd svc
go mod init github.com/team/svc
go build -o svc

# Direkt starten — kein npm install, kein node_modules
./svc

Im Dockerfile reicht ein Multi-Stage-Build mit einem schlanken Base-Image:

# Build
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /svc

# Runtime
FROM alpine:3.21
COPY --from=builder /svc /svc
EXPOSE 3000
ENTRYPOINT ["/svc"]

Das Ergebnis: Image unter 20 MB, Startzeit unter einer Sekunde, kein Layer-Cache-Drama durch tausende npm-Pakete.

Fazit

Go ist die bessere Wahl, wenn du Ressourcen pro Instanz minimieren, Container schnell skalieren oder in serverless Umgebungen mit kalten Starts leben musst. Node.js bleibt die effizientere Lösung, wenn das Team bereits tief im TypeScript-Ökosystem verankert ist und du mehr Bibliotheken als minimale Laufzeit brauchst.

Die Metrik, die für uns zählt: Die Zeit vom Code-Commit bis zum ersten Request im Lasttest ging von etwa dreißig Sekunden auf unter zehn Sekunden runter — weil das Image kleiner ist, die Instanz schneller startet und wir weniger Zeit mit Dependency-Updates verbringen.

Hat jemand von euch den umgekehrten Weg gemacht — also von Go zurück zu Node.js? Ich bin neugierig, welche Team- oder Produktgründe dagegen gesprochen haben.


Siehe auch

  • [Weg A oder Weg B — Synchron oder Asynchron?](/blog/weg-a-oder-weg-b-synchron-oder-asynchron/)
  • [Nginx oder Caddy? Eine Architekturentscheidung](/blog/nginx-oder-caddy-eine-architekturentscheidung/)
  • [Weg A oder Weg B — Systemd oder Docker?](/blog/weg-a-oder-weg-b-systemd-oder-docker/)