Dawloom
All posts

Go, Node, or Bun for your backend

Dawloom engineering6 min read

We haven’t shipped a public backend project on Go or Bun yet. FurnitureAxis runs on Prisma and PostgreSQL behind a Next.js app, plus a C# background worker for the vendor sync, so none of what follows is a Dawloom case study. It’s the reasoning we walk clients through when the question is which runtime to build the backend on, checked against each project’s own documentation rather than vibes.

What each one actually is

Go is a compiled language with concurrency built into the language itself. You launch a goroutine by putting go in front of a function call, and goroutines are cheap enough that Go’s own documentation describes them as costing “little more than the allocation of stack space.” Channels, created with make, are how goroutines talk to each other and stay synchronized without you managing locks by hand. go build compiles your program and its dependencies into a single binary, and because Go always treats the build target as separate from the machine you’re building on, cross-compiling for another OS or architecture is just setting GOOS and GOARCH before you run the build, according to Go’s source installation docs.

Node.js is a JavaScript runtime built around an event loop instead of one thread per request. Its own docs describe it as “an asynchronous event-driven JavaScript runtime, designed to build scalable network applications,” where a callback fires on each connection and the process sleeps when there’s no work to do. Almost nothing in Node blocks on I/O by default, which is why a single Node process can hold open a large number of concurrent connections without spinning up a thread for each one.

Bun is newer and bundles more into one tool. Its own site calls it “a fast, incrementally adoptable all-in-one JavaScript, TypeScript & JSX toolkit,” combining a runtime, an npm-compatible package manager, a Jest-compatible test runner, and a bundler. Bun aims for what its docs call “100% Node.js compatibility,” implementing Node APIs like fs, path, and Buffer natively rather than through a shim, and it’s built to be a drop-in replacement rather than a separate ecosystem.

Three columns comparing Go, Node.js, and Bun on team fluency, throughput model, deployment shape, and ecosystem depth

Team fluency comes before the benchmarks

A lot of comparisons treat “which is faster” as the deciding question and team skill as a tiebreaker. We do it the other way round. If your team writes TypeScript every day and nobody on it has shipped Rust or Go in production, Node or Bun get you shipping this week instead of after a language ramp-up. Go’s syntax is small and the tour is genuinely short, but small syntax doesn’t mean small learning curve: goroutines, channels, and Go’s approach to error handling (no exceptions, explicit if err != nil checks everywhere) are a different mental model from async/await, and a team’s first production Go service is rarely its best one.

That’s not an argument against Go. It’s an argument for being honest about the cost of a language switch before comparing runtime benchmarks that won’t matter if the team ships the wrong thing slowly.

Throughput model: threads you don’t manage vs an event loop you do

Go’s concurrency model hands you real parallelism. Goroutines get multiplexed across OS threads by the Go runtime, so CPU-bound work on a multi-core machine can actually run in parallel, and a blocked goroutine (say, waiting on a slow database call) doesn’t stall the others. For a service doing heavy computation alongside I/O, that matters.

Node and Bun are both single-threaded at the JavaScript layer. Concurrency comes from the event loop and non-blocking I/O, which is excellent for a service that’s mostly waiting on network calls or database queries and comparatively weak for CPU-bound work, since a long synchronous computation blocks everything else on that thread. Node’s own docs are direct about this trade: threaded networking is “relatively inefficient and very difficult to use,” so Node opted for callbacks and an event loop instead of a thread per connection. That’s the right trade for an API that spends its time waiting on a database, and the wrong one for a service doing sustained number crunching in the request path. Node does offer child_process.fork() and a cluster module to spread work across cores when you need it, but that’s an opt-in you reach for, not the default.

Here’s what a goroutine looks like in practice, from Go’s own Effective Go:

c := make(chan int)
go func() {
    list.Sort()
    c <- 1
}()
doSomethingForAWhile()
<-c

The sort runs concurrently, and the unbuffered channel blocks the main function until it’s done. No thread pool to configure.

Deployment shape: one binary vs a runtime you carry with you

This is where Go’s compiled model shows up as an operational difference. go build produces a single static binary, and a Go user quoted on the language’s own homepage points out that this makes services easy to containerize: you copy one file into a minimal image, and there’s no runtime version to match on the target machine. For teams running Kubernetes or any container orchestrator, that’s less to get wrong at deploy time.

Node needs the Node runtime installed wherever your app runs, whether that’s a system install, a Docker base image, or something like nvm managing versions per project. That’s a well-worn path with mature tooling, and it’s still one more moving part than a binary you can scp and run.

Bun narrows that gap for JavaScript. Its bundler supports a --compile flag that packages your app, its dependencies, and a copy of the Bun runtime into a single executable, with cross-compilation to Linux, Windows, and macOS targets from one machine. You still can’t get Go’s small binary sizes since a JS runtime is being bundled in, but you get the same “copy one file, run it” deploy story that used to be Go’s alone. Deploying to production, Bun’s own docs recommend adding --minify and --sourcemap to that build, and bytecode compilation to speed up startup further.

Ecosystem depth

Node’s ecosystem is the deepest of the three by a wide margin, simply because it’s the oldest and JavaScript’s package registry is the default for anyone doing web work of any kind. If you need a library for something specific (a particular OAuth provider, a niche file format, an obscure protocol) the odds of finding a maintained npm package are good, and that library will almost certainly work under Bun too, given Bun’s Node compatibility target.

Go’s standard library is unusually complete for a language its age; HTTP servers, cryptography, and JSON handling are built in rather than bolted on through packages, which keeps dependency trees smaller. What Go doesn’t have is npm’s sheer volume: for a genuinely obscure integration, you’re more likely to be writing the client yourself.

Bun inherits Node’s ecosystem by design, since Node compatibility is a stated goal of the project. The place this gets interesting is Bun’s own tooling: install, test, and bundle are one binary instead of three separate tools (npm, Jest or Vitest, webpack or esbuild) glued together with config.

How we’d actually choose

For a team already writing TypeScript, doing typical web-app backend work (REST or GraphQL APIs, CRUD against a database, calling third-party services), we’d default to Node, because it’s the most battle-tested of the three and the ecosystem risk is lowest. If that team wants faster local iteration, a faster package manager, and less config gluing test and build tooling together, Bun is worth adopting for the same codebase, since it’s meant as a drop-in.

For a service that’s CPU-bound, needs real parallelism, or benefits from a single static binary shipped into a minimal container (a queue worker, a data pipeline, something latency-sensitive at the infrastructure layer) Go earns the switch even with the ramp-up cost, provided the team has the runway to learn it properly.

None of this replaces looking at your actual service. A latency-sensitive API doing mostly I/O might be better served by Node’s maturity than Go’s raw throughput, and a small internal tool might not need Bun’s bundler features at all. If you’re scoping a backend and want the reasoning applied to your actual constraints instead of a generic comparison, tell us what you’re building and we’ll work through it with you.

Got something to build?

Tell us what you need. An engineer replies, not a sales team.

Search the whole site