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.
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.
|  |
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.
| TypeScript | Kotlin | |
|---|---|---|
| Null safety | strictNullChecks (opt-in, erased) | Built in, enforced at runtime |
| Payload matches type | Only with a runtime validator | Yes, via serializer |
| Union / ADT modelling | Excellent | Good (sealed classes) |
| Escape hatch frequency | any / 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 step | TypeScript (Fastify) | Kotlin (Ktor) |
|---|---|---|
| Type-check only | 1.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 + tests | 48 s | 2 min 40 s |
| Container image | 180 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:
| Stack | req/s | p50 | p99 | RSS after run |
|---|---|---|---|---|
| TypeScript - Fastify (1 process) | 31,800 | 7.1 ms | 18.4 ms | 142 MB |
| TypeScript - NestJS (1 process) | 21,400 | 10.9 ms | 27.9 ms | 188 MB |
| Kotlin - Ktor (virtual threads) | 58,900 | 3.9 ms | 9.1 ms | 412 MB |
| Kotlin - Spring Boot (virtual threads) | 44,200 | 5.2 ms | 12.6 ms | 520 MB |
Three things worth saying before anyone quotes these:
- Node is single-threaded here on purpose. With
clusterand 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. - 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.
- 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.
Related
- Node.js 22 vs Deno 2 vs Bun 1.1: HTTP JSON API Throughput, Measured with wrk - the JavaScript runtime under the TypeScript numbers above.
- Java 21 Virtual Threads vs .NET 8 async/await: Blocking I/O Throughput, Measured with wrk - the JVM runtime under the Kotlin numbers.
- pg vs Prisma vs Drizzle: PostgreSQL Driver Latency from Node.js 22, Measured with autocannon - why the NestJS version lost ground in the ORM.
- Go Goroutines vs .NET Tasks: HTTP Concurrency Throughput, Measured with wrk - the process-vs-thread trade-off behind Node cluster mode.
- Python vs Rust in a Hot Loop: What 10 Million Iterations Cost, Measured with hyperfine - another language comparison in this series.
