Post

TypeScript vs Kotlin for Backend Services: Type Safety, Tooling, Performance and Team Velocity

TypeScript (Node 22) vs Kotlin (JVM 21) for the same REST API: compile-time safety, build loops, wrk throughput and p99 latency, and which team ships faster.

TypeScript vs Kotlin for Backend Services: Type Safety, Tooling, Performance and Team Velocity

The Performance folder has already covered the runtimes underneath these two languages: Node vs Deno vs Bun for the JavaScript side and Java 21 virtual threads for the JVM side. What those posts do not answer is the question a team actually asks when it starts a new service: should we write it in TypeScript or in Kotlin? Both are statically typed, both are pleasant to write, and both have a mature HTTP stack. The differences are in where the type system stops, how fast the inner loop is, and what the runtime costs you at p99.

I built the same small order-lookup API four times - TypeScript with Fastify and with NestJS, Kotlin with Ktor and with Spring Boot - and compared them on four axes: type safety, tooling, performance and team velocity. The numbers are from one 4 vCPU box with PostgreSQL on a second box, so treat them as relative, not absolute.

The service under test

  • GET /orders/:id - one primary-key lookup, JSON response.
  • POST /orders - validate a body with six fields, insert, return 201.
  • GET /health - no I/O, so we can see pure framework overhead.

Each version uses the idiomatic stack for its ecosystem: Fastify + pg + zod, NestJS + Prisma + class-validator, Ktor + Exposed + kotlinx.serialization, Spring Boot 3.3 + Spring Data JDBC + Jakarta Validation. Node 22.9 and Temurin 21.0.4 with virtual threads enabled in Spring Boot.

Type safety: where each compiler stops

Both languages catch the classic backend bug at build time - a route parameter that might be missing flowing into a function that wants a definite string.

![Terminal output showing tsc reporting TS2345 for a stringundefined route parameter in 1.9 seconds, and the Kotlin compiler reporting a String? to String type mismatch for the same bug in 14.2 seconds](/assets/img/posts/languages/typescript-vs-kotlin-compiler-output.webp)

The difference is what happens after the compiler. TypeScript’s types are erased: the Order type on your handler says nothing about the JSON that actually arrived. Unless you add a runtime validator (zod, typebox, class-validator) and derive the static type from it, the type system is describing what you hope the payload is. Every TypeScript team I have worked with has shipped a Cannot read properties of undefined to production at least once because a type and a payload disagreed.

Kotlin’s types are real at runtime. kotlinx.serialization or Jackson will reject a payload that does not match the data class, and a String field is non-null all the way down. You still need validation for business rules, but you do not need it to make the type system true.

Where TypeScript wins back ground is expressiveness. Discriminated unions, template literal types and satisfies make it easy to model API responses precisely; Kotlin’s sealed classes do the same job but with more ceremony, and generic variance is harder to get right on the JVM.

 TypeScriptKotlin
Null safetystrictNullChecks (opt-in, erased)Built in, enforced at runtime
Payload matches typeOnly with a runtime validatorYes, via serializer
Union / ADT modellingExcellentGood (sealed classes)
Escape hatch frequencyany / as show up often!! is rare and lint-flagged

Tooling and the inner loop

The compiler screenshot above hints at the daily experience. Measured on the same machine, warm caches:

Loop stepTypeScript (Fastify)Kotlin (Ktor)
Type-check only1.9 s (tsc --noEmit)14.2 s (gradlew compileKotlin, daemon warm)
Hot reload after one-line edit~0.4 s (tsx watch)~6 s (Gradle continuous + Ktor auto-reload)
Unit test suite (120 tests)3.1 s (vitest)11.8 s (JUnit 5 + Gradle)
Clean CI build + tests48 s2 min 40 s
Container image180 MB (node:22-slim)310 MB (temurin:21-jre)

Kotlin’s compile time is the single biggest complaint from teams coming from TypeScript, and it is real. K2 (Kotlin 2.0) cut it roughly in half versus 1.9 in my measurements, but Gradle configuration time and JVM warm-up still dominate short edits. On the other hand, IntelliJ’s refactoring across a Kotlin codebase is noticeably more reliable than VS Code’s across a large TypeScript monorepo, where project references and path aliases regularly break “rename symbol”.

Dependency management is the quieter difference. The node_modules tree for the NestJS version pulled 612 packages; the Spring Boot version pulled 71 artifacts. Fewer, bigger, better-curated dependencies mean fewer supply-chain alerts to triage every week.

Performance

wrk -t8 -c256 -d60s against GET /orders/:id, Postgres hot in cache, after a 30 s warm-up so the JIT in both runtimes had settled:

Stackreq/sp50p99RSS after run
TypeScript - Fastify (1 process)31,8007.1 ms18.4 ms142 MB
TypeScript - NestJS (1 process)21,40010.9 ms27.9 ms188 MB
Kotlin - Ktor (virtual threads)58,9003.9 ms9.1 ms412 MB
Kotlin - Spring Boot (virtual threads)44,2005.2 ms12.6 ms520 MB

Three things worth saying before anyone quotes these:

  1. Node is single-threaded here on purpose. With cluster and four workers Fastify reached 94,000 req/s, ahead of Ktor’s single JVM. But four processes means four connection pools, four caches and four copies of your in-memory state, which is exactly the trade-off Go vs .NET concurrency spent a whole post on.
  2. Memory is the JVM’s price. The Kotlin services idle at 3-4x the RSS of the Node ones. If you run many small services on a shared Kubernetes node, that bill is real.
  3. Cold start was 0.3 s for Fastify, 0.9 s for NestJS, 1.4 s for Ktor and 3.8 s for Spring Boot. For a long-running API this is irrelevant; for scale-to-zero or Lambda it decides the question outright in TypeScript’s favour (or pushes you to GraalVM native images, which is its own post).

The Postgres side was the same for all four - the driver differences in pg vs Prisma vs Drizzle show up again here, with the Prisma-based NestJS version losing most of its gap to Fastify in the ORM rather than the framework.

Team velocity

Benchmarks are the easy part. The harder question is who ships features faster six months in, and the honest answer depends on the team you already have.

TypeScript wins when:

  • The frontend is already TypeScript and the same engineers own the API. Shared types between client and server (or tRPC) remove a whole class of integration bugs.
  • The service is small, event-driven or serverless, where cold start and memory matter more than p99.
  • Hiring speed matters - the TypeScript pool is several times larger.

Kotlin wins when:

  • The service is long-lived, CPU-heavy or latency-sensitive and runs on a fixed pool of machines.
  • You already have JVM infrastructure: Kafka clients, observability agents, internal libraries. Kotlin reuses every Java artifact without a bridge.
  • The team is burned by runtime type bugs. The “it compiled, it runs” feeling is noticeably stronger in Kotlin, and in our incident log over two years the TypeScript services had about twice the rate of type-shaped production errors per service.

Coroutines versus async/await were a wash in practice. Both are structured enough; Kotlin’s structured concurrency catches leaked background work that Node silently lets run, but Node’s single-threaded model means you never debug a data race.

Which would I pick?

For a new, standalone HTTP API with a known load profile and a team that can learn either: Kotlin with Ktor. It gave the best p99 by a factor of two, its type system holds at runtime, and K2 has taken most of the sting out of the compile loop.

For anything that must start cold, run at the edge, or sit next to a TypeScript frontend owned by the same people: TypeScript with Fastify, with every payload validated at the boundary with zod and strict: true in tsconfig, no exceptions.

Neither choice is wrong. The costly mistake is picking one and then skipping the discipline it needs - runtime validation in TypeScript, memory budgets and startup planning in Kotlin.

This post is licensed under CC BY 4.0 by the author.