Skip to content
Joven Dalian, home
All posts

Backend Engineering8 min read

Go vs NestJS: The Backend Decision You’ll Thank Yourself For Later

Node.js and NestJS are excellent until the constraints change. This is an honest comparison: what NestJS gets right, where it starts to cost you, what Go gives you and what it costs, and the signals that mean it’s time to evaluate a switch.

I was upgrading a Cloud Function from Node.js 20 to 24, which should have been routine. The function had been running on 256MB of RAM for over a year without complaining. After the upgrade, it wouldn't start. I bumped it to 512MB and it came back to life. Same code, same logic, same load. A runtime version bump had quietly doubled the memory footprint.

Out of curiosity, I rewrote that function in Go. Same logic, same database calls, same external API integrations. It ran in 128MB and never moved. That's when I went back and re-examined something I'd assumed was settled: when should you actually reach for Go, and when do Node and NestJS still win? This is my honest answer, including the parts that don't flatter Go.

First, be fair to NestJS

Before comparing anything, I want to be clear: NestJS is excellent software. It brought structure to a Node ecosystem that badly needed it, and its opinionated architecture, decorator-based DI, and TypeScript-first design have helped a lot of teams ship backends they can still maintain three years later.

But "not a bad decision" and "the right decision for your context" are different claims. That gap is what this post is about.

Developer velocity out of the box. NestJS gets you moving in a way Go doesn't. With decorators, auto-wiring, and a CLI that scaffolds modules, guards, and interceptors, an experienced team can have a clean CRUD API in an afternoon.

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(id);
  }
}

That's readable and intention-revealing, and there's nothing wrong with wanting it.

The npm ecosystem. Stripe, Twilio, PDF generation, OpenAI: there's a first-party SDK for everything, and it targets Node first. In Go you're occasionally on a community client where Node would have had an official one with better docs. The gap is closing, but it's real.

JSON is home territory. JavaScript was built for it. Your database documents, API payloads, and business objects stay one coherent mental model with no mapping layer in between.

Shared language with the frontend. If the UI is React, the team already speaks TypeScript. That lowers the barrier for full-stack contributions, code review, and shared validation logic, and it's worth more than it sounds on paper.

Where NestJS starts to show cracks

Memory is the big one. A minimal NestJS app at idle typically sits at 120–300MB depending on what you've imported. Add Prisma, Passport, class-validator, and Bull, and you're near that ceiling before the first request arrives. That's not really NestJS's fault. It's V8: the JIT, garbage collector, and module system all carry weight. For a monolith serving thousands of users it's fine. For microservices or serverless where you pay per GB-hour, it compounds.

Startup time. Module initialisation, metadata reflection, and DI container setup mean a cold Lambda or Cloud Function is often 2–5 seconds from invocation to first served request. In serverless-heavy architectures this is frequently the deciding factor: users feel the cold start, and infrastructure pays for it.

TypeScript's safety has a ceiling. Types exist at compile time only. At runtime you're back in JavaScript, and one as any in the wrong place, one unvalidated JSON.parse, one bad cast at a boundary, and your "typed" codebase is making assumptions that can only fail in production. This doesn't make TypeScript bad. The guarantee is just softer than it looks, and most Node services have a few runtime type surprises in their incident history to prove it.

Concurrency takes workarounds. The event loop handles I/O-bound concurrency very well. But as soon as you have CPU-bound work like image processing, large transforms, or cryptography, you're into Worker Threads, which are awkward to use correctly and easy to misuse.

Side-by-side runtime footprint of a NestJS service and an equivalent Go service across three axes: cold start of roughly 2 to 5 seconds versus 50 to 150 milliseconds, idle memory of 120 to 300MB versus 10 to 50MB, and throughput of 5,000 to 15,000 requests per second versus 30,000 to 80,000, with container image size of 300 to 900MB versus under 20MB shown underneath

What Go gets right

Performance that compounds. In the services I've measured, Go lands somewhere around 3–10x better memory efficiency and materially higher throughput than the Node equivalent. The exact multiple depends entirely on the workload, but the shape is consistent: cold start drops from seconds to tens of milliseconds, idle memory drops from hundreds of megabytes to tens, and throughput goes up several times over on the same hardware. Treat those as order-of-magnitude expectations, not promises, and benchmark your own workload before you put a number in a planning doc.

Goroutines are concurrency done right. They're the core execution model, not something bolted onto a single-threaded runtime:

func (s *service) ProcessBatch(ctx context.Context, items []Item) error {
	g, ctx := errgroup.WithContext(ctx)
	for _, item := range items {
		g.Go(func() error {
			return s.processOne(ctx, item)
		})
	}
	return g.Wait()
}

No callbacks, no Promise.all edge cases around partial failure, no Worker Thread boilerplate. errgroup cancels the shared context on the first error and Wait returns it. Goroutines are cheap enough that spawning thousands is ordinary, idiomatic code rather than a design decision you have to defend.

The deployment story is one line.

GOOS=linux GOARCH=amd64 go build -o api ./cmd/api

That's the entire artefact: a single static binary with no runtime to install, no node_modules, and no version manager. Alpine-based Go images routinely land under 20MB; the Node equivalent is usually 300–900MB. That difference shows up in every image pull, every cold start, and every CVE scan.

Error handling that forces honesty.

user, err := repo.GetByID(ctx, id)
if err != nil {
	return nil, fmt.Errorf("userService.GetByID: %w", err)
}

Every error is explicit. You can't ignore one without writing code that deliberately ignores it. In JavaScript, a forgotten await turns a rejected promise into a silently dead request handler. Go's error handling looks like boilerplate, but it's doing real work.

Where Go hurts

Pretending Go has no friction helps nobody, and every one of these is a real reason teams bounce off it.

Verbosity fatigue. The same explicitness that makes Go safe makes it repetitive. You will write if err != nil hundreds of times. Some people find it meditative; others burn out on it.

Slower prototyping. No reflection-based magic means more setup before you see results. The first week in Go is slower than the first week in NestJS, and a sprint of training won't change that.

The OOP mental shift. If your team thinks in classes, inheritance, and decorators, Go's composition-over-inheritance model takes real unlearning. It's a different paradigm, not different syntax.

Generics are recent. They landed in 1.18 and the ecosystem is still catching up. If you're used to TypeScript's utility types, Go's generics will feel constrained.

None of these are reasons to avoid Go. They're reasons to go in with calibrated expectations instead of a blog post's enthusiasm.

The honest tradeoff

Go tends to win on memory efficiency, cold start, throughput, concurrency for CPU-bound work, deployment simplicity, and the long-term maintainability that comes from explicit error paths and runtime type safety.

NestJS tends to win on initial velocity, ecosystem breadth, full-stack coherence, team familiarity, and prototyping speed.

Notice that those two lists aren't measuring the same thing. One is about what the system costs to run and keep correct; the other is about what it costs to build and staff. Which list matters more is a fact about your organisation, not about the languages.

Decision matrix splitting backend context by which cost dominates. Stay on NestJS when the cost of building dominates, signalled by iteration speed as the binding constraint, SDK-heavy platform work, a TypeScript-strong team, and full-stack coherence. Move to Go when the cost of running dominates, signalled by cold starts that are visible or billed, throughput or CPU-bound work, correctness over a long horizon, and lean infrastructure. The timing rule: the worst moment to adopt Go is when the system is already breaking under scale

The case for staying on NestJS. If your team is strong in TypeScript and the organisation can't absorb a language change, stay where you are. The productivity cost of switching languages is real and routinely underestimated. A well-run NestJS codebase will beat a Go codebase written by a team that doesn't know Go, every time. If you're integrating with platforms that only ship Node SDKs, the workaround tax in Go may not be worth paying. And if iteration speed is the binding constraint while scale is still years out, NestJS's scaffolding advantage is the thing that actually matters.

The case for switching to Go. If you're running microservices or serverless functions where cold starts are visible to users or expensive on the invoice, Go should be the first thing you evaluate. It isn't automatically the answer, but it belongs at the top of the list. If you're approaching horizontal scaling that a more efficient runtime would have made unnecessary, Go converts directly into infrastructure cost you don't spend. And if the team is willing to spend four to six weeks learning Go's idioms, not just its syntax, the maintainability payoff keeps coming long after the learning curve is behind you.

One thing I didn't expect: AI-assisted development

I hadn't expected this when I started writing Go seriously, but AI-assisted coding works noticeably better with Go's strictness.

When a model generates Go that compiles and passes go vet, it's usually correct. The compiler rejects ambiguity, unused variables are an error, and there's no undefined is not a function hiding three layers down a call chain. Generated TypeScript can be syntactically valid and semantically wrong in ways the compiler will happily wave through. Go's structure is also exactly what agents need to stay consistent across files: predictable package layout, explicit dependencies, no decorator metaphysics to infer.

That's not a reason to pick a language on its own. But if a meaningful share of your code is now written with an agent in the loop, "the compiler catches the model's mistakes" has become a real engineering property worth pricing in.

Conclusion

Choosing between Go and NestJS has less to do with which language is better than with which constraints dominate in your context. NestJS is a mature, productive framework for teams that move fast, value ecosystem breadth, or are deeply invested in TypeScript, and there's nothing wrong with choosing it. Go is right when you need efficiency, want to run lean infrastructure, or are building something that has to stay correct and maintainable over a long horizon.

The worst time to adopt Go is when your system is already breaking under scale, because that's exactly when you can least afford a rewrite. Run the evaluation while things are calm, on a service small enough that being wrong is cheap.

NEXT POSTThe Six Claude Code Skills I’d Reach For on Real ProjectsDeveloper Workflow · June 17, 2026Read post
WORK WITH ME

Building something similar?

I read every message and usually reply within one business day.

Let’s talk