Skip to main content

ADR-0006: Node.js 20 LTS as Runtime Environment

Status

Accepted - 2025-01-26


Context

TVL Platform requires a JavaScript runtime environment for backend services, background workers, and tooling with the following requirements:

Business Requirements

  • Fast development velocity (< 3 months for MVP.0)
  • Low operational overhead (small team)
  • Cost-effective for MVP and scale
  • Easy to hire developers

Technical Requirements

  • Execute TypeScript/JavaScript code server-side
  • High performance for API server (target: <200ms p95)
  • Support for concurrent background workers
  • Rich ecosystem of libraries (database, Redis, HTTP, etc.)
  • Long-term support (LTS) for stability

Constraints

  • Frontend uses React (JavaScript ecosystem)
  • Team has JavaScript/TypeScript experience
  • Must run on Docker (containerized deployment)
  • PostgreSQL and Redis clients needed

Decision

Node.js 20 LTS (Long-Term Support) as the runtime environment for all backend code.

Version Pinning

// package.json
{
"engines": {
"node": ">=20.0.0",
"pnpm": ">=8.0.0"
}
}
# Dockerfile
FROM node:20-alpine
20

Rationale

  1. LTS Stability: Node.js 20 LTS supported until April 2026 (18 months runway)
  2. Performance: V8 engine performance improvements (20% faster than v18)
  3. Modern Features: Native test runner, fetch API, Web Streams
  4. Ecosystem: 2.1 million npm packages available
  5. Team Expertise: Team already proficient in JavaScript/TypeScript

Alternatives Considered

Alternative 1: Bun

Rejected (for now)

Pros:

  • 3x faster than Node.js (startup and runtime)
  • Built-in TypeScript support (no compilation needed)
  • Compatible with Node.js APIs
  • Built-in bundler and test runner

Cons:

  • Not yet production-ready (v1.0 released Oct 2023, still maturing)
  • Smaller ecosystem (some npm packages incompatible)
  • Less mature error handling and debugging
  • Riskier for production workloads

Decision: Revisit Bun in 12-18 months when ecosystem matures.


Alternative 2: Deno

Rejected

Pros:

  • Secure by default (permissions model)
  • Native TypeScript support
  • Modern standard library
  • Better module system (ESM-first)

Cons:

  • Incompatible with npm packages (requires import maps)
  • Would need to rewrite npm dependencies
  • Smaller ecosystem
  • Less hiring pool (fewer Deno developers)

Decision: Node.js ecosystem too valuable to abandon.


Alternative 3: Node.js 18 LTS

Rejected

Pros:

  • More mature (released April 2022)
  • Longer track record

Cons:

  • Node.js 20 LTS already stable (released April 2023)
  • Missing performance improvements from v20
  • No significant risk using v20 (LTS = stable)

Decision: Node.js 20 offers better performance with same stability.


Alternative 4: Python (FastAPI/Django)

Rejected

Pros:

  • Excellent for data science and ML
  • Great async support (asyncio)
  • Easy to learn

Cons:

  • Cannot share code with frontend (different language)
  • Slower for I/O-bound workloads (GIL limits concurrency)
  • Less suitable for real-time systems
  • Team lacks Python expertise

Decision: Node.js better for full-stack JavaScript.


Consequences

Positive

  1. Performance

    • V8 JIT compiler optimizes hot paths
    • Event loop handles 10,000+ concurrent connections
    • Streaming I/O for large payloads
    • Performance: <50ms API response times achievable
  2. Ecosystem

    • 2.1 million npm packages
    • Excellent libraries: Fastify, Drizzle, BullMQ, Zod
    • Active community and support
  3. Developer Experience

    • Single language for frontend + backend (code sharing)
    • Excellent debugging (Chrome DevTools, VS Code debugger)
    • Fast iteration (no compilation for JS, incremental for TS)
  4. Deployment

    • Small Docker images (node:20-alpine is 40MB)
    • Fast cold starts (<1 second)
    • Easy to containerize
  5. Hiring

    • Large talent pool (Node.js is #6 most used technology - Stack Overflow 2024)
    • Easy to train frontend developers for backend

Negative

  1. Single-Threaded Event Loop

    • CPU-intensive tasks block event loop
    • Mitigation: Use worker threads for CPU-bound work, or offload to background workers
  2. Memory Management

    • V8 garbage collection can cause latency spikes
    • Mitigation: Tune GC flags (--max-old-space-size), use memory profiling
  3. Callback Hell / Async Complexity

    • Async code can be difficult to reason about
    • Mitigation: Use async/await (native in Node.js 20), avoid callbacks
  4. NPM Dependency Hell

    • Dependency conflicts and security vulnerabilities
    • Mitigation: Use pnpm (strict dependencies), Dependabot (security alerts), lock files

Node.js 20 Features Used

1. Native Test Runner

// No need for Jest/Vitest for simple tests
import { test, describe } from 'node:test';
import assert from 'node:assert';

describe('calculateTotal', () => {
test('should add price and tax', () => {
assert.strictEqual(calculateTotal(100, 0.1), 110);
});
});

2. Native Fetch API

// No need for axios or node-fetch
const response = await fetch('https://api.hostaway.com/listings', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();

3. Web Streams API

// Stream large CSV exports
const stream = fs.createReadStream('bookings.csv');
const response = new Response(stream);

4. Performance Hooks

import { PerformanceObserver } from 'node:perf_hooks';

const obs = new PerformanceObserver((items) => {
console.log(items.getEntries()[0].duration);
});
obs.observe({ entryTypes: ['measure'] });

Implementation Guidelines

1. Pin Node.js Version

All environments (dev, staging, production) must use Node.js 20:

FROM node:20-alpine

WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
CMD ["node", "dist/server.js"]

2. Use Native APIs Where Possible

Prefer Node.js built-ins over npm packages:

// ✅ Good - native
import { test } from 'node:test';
await fetch('https://api.example.com');

// ❌ Avoid - unnecessary dependency
import axios from 'axios';

3. Configure V8 Heap Size

For production workloads:

# Increase heap size to 2GB (default is 512MB)
node --max-old-space-size=2048 dist/server.js

4. Enable Source Maps

For better error stack traces:

// tsconfig.json
{
"compilerOptions": {
"sourceMap": true
}
}
# Run with source map support
node --enable-source-maps dist/server.js

Migration Path

When to Upgrade

  • Node.js 22 LTS (April 2025): Evaluate migration in Q3 2025
  • Triggers: Security vulnerabilities, performance improvements, critical features

Upgrade Process

  1. Test in development environment
  2. Update Docker base image (FROM node:22-alpine)
  3. Update .nvmrc and package.json engines
  4. Run full test suite
  5. Deploy to staging for 1 week
  6. Deploy to production with canary rollout

Validation Checklist

  • All Dockerfiles use node:20-alpine
  • .nvmrc file pinned to 20
  • package.json engines field requires Node.js 20+
  • CI/CD uses Node.js 20
  • Development team has Node.js 20 installed (via nvm)
  • Source maps enabled for production debugging
  • V8 heap size configured for production workloads

References