The API choice: binary vs runtime
You're building an API for a new service. You know TypeScript from the frontend, and it feels natural to keep using it for the backend. A teammate suggests Go. You hear "Go is faster" and "Go is for systems," but you also hear "TypeScript has better tooling" and "TypeScript is easier to hire for." The choice isn't just about speed. It's about how the language handles memory, concurrency, and the shape of your deployment.
Go compiles to a single executable. The binary contains everything. No runtime installation on the server. TypeScript runs on Node.js. Node.js is a runtime that executes JavaScript. The code doesn't compile to machine code in the same way; it runs on a virtual machine. Go uses goroutines for concurrency. These are lightweight threads managed by the Go runtime. TypeScript uses an event loop. One thread handles all requests, switching between them when I/O waits.
How the languages run
Go is a compiled language. You write code, run go build, and get a binary file. That binary runs on the target machine without any additional software. The Go compiler optimizes the code for the specific architecture. The binary includes the Go runtime, the garbage collector, and all your dependencies. You can copy the binary to a server and run it.
TypeScript is a superset of JavaScript. The TypeScript compiler strips type annotations and outputs JavaScript. That JavaScript runs on a runtime like Node.js. Node.js provides the environment, including the event loop, file system access, and networking. You need to install Node.js on the server. You also need to manage dependencies with a package manager. The deployment includes the source code, the runtime, and the dependencies.
Go's concurrency model is based on goroutines. A goroutine is a function running concurrently with other functions. Goroutines are cheap. You can spawn thousands or millions of them. The Go runtime multiplexes goroutines onto a small number of OS threads. If a goroutine blocks on I/O, the runtime moves other goroutines to the free thread. This keeps the system responsive.
TypeScript's concurrency model relies on the event loop. The event loop is a single thread that processes events. When a request arrives, the handler runs. If the handler needs to wait for I/O, it yields control back to the event loop. The event loop picks up the next event. When the I/O completes, the callback runs. This model works well for I/O-bound workloads. CPU-bound work blocks the event loop and stops the server from handling other requests.
Go gives you a binary. TypeScript gives you a process.
Minimal server example
Here's a simple HTTP server in Go. The standard library includes everything you need. No external packages.
package main
import (
"fmt"
"net/http"
)
// HandleRoot writes a greeting to the response.
func HandleRoot(w http.ResponseWriter, r *http.Request) {
// Write directly to the response writer.
fmt.Fprint(w, "Hello from Go")
}
func main() {
// Register the handler for the root path.
http.HandleFunc("/", HandleRoot)
// Start listening on port 8080.
http.ListenAndServe(":8080", nil)
}
Here's the equivalent in TypeScript. You need Express, a popular web framework.
import express from 'express';
const app = express();
// Define a GET route for the root path.
app.get('/', (req, res) => {
// Send a text response.
res.send('Hello from TypeScript');
});
// Start the server on port 8080.
app.listen(8080, () => {
console.log('Server running on port 8080');
});
The Go code compiles to a single file. Run go build and you get an executable. The TypeScript code requires node to run. You also need npm install express to get the dependency. The Go handler receives the request and response as arguments. The TypeScript handler receives them via the callback.
What happens under the hood
When the Go server starts, http.ListenAndServe creates a listener on the port. It spawns a goroutine to accept connections. For each connection, it spawns a new goroutine to handle the request. If ten thousand requests arrive simultaneously, the server spawns ten thousand goroutines. The runtime schedules them efficiently. Memory usage stays low because goroutines start with a small stack that grows as needed.
When the TypeScript server starts, app.listen creates a listener. The event loop begins processing events. When a request arrives, the event loop invokes the handler. The handler runs to completion or yields. If the handler yields, the event loop moves to the next event. If ten thousand requests arrive, the server handles them one by one, interleaving I/O operations. Memory usage is low per request, but the single thread limits parallelism. You can't use multiple CPU cores for request handling without worker threads.
Go's garbage collector runs concurrently with the program. It pauses the world for very short intervals, usually less than a millisecond. This keeps latency predictable. TypeScript's garbage collector runs on the event loop thread. It can pause the loop for longer periods, especially with large heaps. This can cause latency spikes.
Go makes the unhappy path visible. TypeScript hides it behind promises.
Realistic API handler
Real APIs need error handling, timeouts, and structured data. Go forces you to handle errors explicitly. TypeScript uses exceptions and async/await.
package main
import (
"context"
"encoding/json"
"net/http"
"time"
)
// User represents a user in the system.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
// FetchUser retrieves a user by ID from a simulated database.
// Context is the first parameter, named ctx.
func FetchUser(ctx context.Context, id int) (User, error) {
// Simulate a database query with a timeout.
select {
case <-ctx.Done():
return User{}, ctx.Err()
case <-time.After(100 * time.Millisecond):
// Return a mock user.
return User{ID: id, Name: "Alice"}, nil
}
}
// HandleUser writes the user JSON or an error message.
func HandleUser(w http.ResponseWriter, r *http.Request) {
// Use the request context for cancellation.
ctx := r.Context()
user, err := FetchUser(ctx, 1)
if err != nil {
// Return a 500 error with the message.
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Set content type and encode JSON.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
The Go code uses context.Context to pass deadlines and cancellation signals. The context is always the first parameter. This is a convention that tools and libraries follow. The function returns a value and an error. The caller checks the error immediately. If the context is cancelled, the function returns early. The select statement waits for either the context or the timeout.
import express, { Request, Response } from 'express';
interface User {
id: number;
name: string;
}
// Simulate fetching a user with a timeout.
async function fetchUser(id: number): Promise<User> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id, name: 'Alice' });
}, 100);
});
}
const app = express();
// Middleware to handle the request.
app.get('/user/:id', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
// Await the async database call.
const user = await fetchUser(id);
res.json(user);
} catch (error) {
// Handle errors in the catch block.
res.status(500).send('Internal Server Error');
}
});
The TypeScript code uses async/await for asynchronous operations. The function returns a promise. The caller awaits the result. Errors are caught in a try/catch block. The type system ensures the shape of the data, but types are erased at runtime. The runtime doesn't know about User. It only knows about objects.
Go uses structs and interfaces. The compiler checks types at compile time. The runtime knows the types. You can use reflection to inspect types. TypeScript uses interfaces and types. The compiler checks types at compile time. The runtime has no type information. You can't check types at runtime without manual checks.
Trust the type system in Go. It works at compile and runtime.
Concurrency and performance
Go shines in concurrent workloads. Goroutines allow you to write concurrent code that looks sequential. You can spawn a goroutine for each request. The runtime handles the scheduling. If you need to communicate between goroutines, use channels. Channels are type-safe pipes. You send values on one end and receive on the other. This prevents race conditions.
TypeScript handles concurrency differently. The event loop processes one task at a time. You can use Promise.all to run multiple async operations in parallel. The operations run concurrently, but the callbacks run on the event loop. If you need CPU-bound concurrency, you must use worker threads. Worker threads are heavier than goroutines. They require more setup and communication overhead.
Go's memory model is simple. Variables are shared between goroutines. The compiler detects data races if you run go run -race. The race detector flags concurrent access to shared memory without synchronization. TypeScript's memory model is based on closures. Variables are captured by reference. Closures can lead to memory leaks if you hold references longer than needed. The garbage collector reclaims memory when references drop to zero.
Go has a garbage collector. TypeScript has a garbage collector. Go's GC is tuned for low latency. TypeScript's GC can have pauses. For high-throughput APIs, Go usually wins. For rapid development and full-stack sharing, TypeScript wins.
A panic in Go stops the world. A crash in Node stops the process.
Pitfalls and runtime errors
Go programs can panic. A panic stops the program and prints a stack trace. Common causes include nil pointer dereferences, out-of-bounds slice access, and division by zero. If you dereference a nil pointer, the runtime panics with runtime error: invalid memory address or nil pointer dereference. You can recover from a panic using defer and recover, but this is rare in production code. Most panics indicate a bug.
TypeScript programs can throw exceptions. An uncaught exception crashes the process. Common causes include accessing properties on undefined, calling functions on non-functions, and type mismatches. If you access a property on undefined, the runtime throws TypeError: Cannot read properties of undefined. You can catch exceptions using try/catch. Unhandled promise rejections also crash the process.
Go requires you to handle errors. The compiler doesn't force you to check errors, but the community expects it. Ignoring errors leads to silent failures. Linters like errcheck catch ignored errors. TypeScript allows you to ignore errors. The type system catches many issues, but runtime errors happen when data doesn't match the shape. Defensive coding is essential.
Go has a convention for error handling. Functions return errors as the last value. The caller checks the error immediately. This makes the error path visible. TypeScript uses exceptions. Errors bubble up. This is cleaner for simple cases but can hide errors in complex async chains.
The worst goroutine bug is the one that never logs.
Decision matrix
Use Go when you need a single binary deployment with no runtime dependencies. Use Go when you have high concurrency requirements and need to handle thousands of simultaneous connections with low memory overhead. Use Go when your team values explicit error handling and wants the compiler to enforce checks. Use Go when you need predictable low-latency performance and control over resource usage. Use Go when you are building infrastructure, microservices, or CLI tools.
Use TypeScript when you are building a full-stack application and want to share types between frontend and backend. Use TypeScript when you need rapid prototyping and access to the massive npm ecosystem. Use TypeScript when your team is already proficient in JavaScript and wants to minimize context switching. Use TypeScript when you are building content-heavy applications with server-side rendering. Use TypeScript when you prefer a flexible ecosystem over strict conventions.
Pick the tool that matches the shape of your problem, not the hype.