When a friendâofâaâfriend calls you with the words âwe have a microâbudget but we trust you,â you either run for the hills or pour yourself another espresso and crack VS Code open. I chose the second option, which is how I ended up spending two very caffeineâcharged weekends deciding whether to build a tiny API with bareâbones JavaScript or to reach for the shinier, batteriesâincluded NestJS framework.
This post is a blowâbyâblow log of that internal knife fight. No corporate buzzwords, no AIâcrafted SEO paragraphsâjust me, my keyboard, and way too many TODO comments. If youâve ever wondered whether NestJS is overkill for something that could fit in a single file, or whether handârolled JavaScript is a maintenance nightmare dressed in hipster minimalism, read on.
The MiniâProject That Started the Drama
Picture a twoâperson startup that sells handâpainted skateboard decks. Theyâd need a teenyâtiny backend that could:
- expose a REST API for product listings (
GETÂ /decks
) - accept orders (
POSTÂ /orders
) - ping Stripeâs webhook once a week
âŚand run on the cheapest $6 VPS the CFO (aka the founderâs dog) could find.
Total budget for dev work: â $1 000.
Deadline:two weekend sprints.
Team:me, my linters, and a fig plant that judges my tab spacing.
With numbers that tight, every hour of setup matters, so the âframework vs. noâframeworkâ decision suddenly felt heavy.
Route 1 â The BareâBones JavaScript Way
Spinning up a Node server by hand is the programming equivalent of camping with just a knife and a roll of duct tape: uncomfortable, but extremely satisfying when it works.
// server.js â 59 lines, all mine
import http from 'node:http';
import { parse } from 'node:url';
import { readFileSync } from 'node:fs';
const PORT = process.env.PORT ?? 3000;
const DB = JSON.parse(readFileSync('./db.json', 'utf8'));
const routes = {
'/decks': (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(DB.decks));
},
'/orders': async (req, res) => {
if (req.method !== 'POST') return send404(res);
const body = await buffer(req);
const order = JSON.parse(body);
// TODO: validation, payment, kittens, etc.
DB.orders.push(order);
res.writeHead(201);
res.end('cool');
}
};
http.createServer((req, res) => {
const path = parse(req.url).pathname;
(routes[path] ?? send404)(req, res);
}).listen(PORT, () => console.log('listening', PORT));
function send404(res) { res.writeHead(404); res.end('nope'); }
function buffer(req) {
return new Promise(res => {
let d = '';
req.on('data', c => (d += c)).on('end', () => res(d));
});
}
Sixty lines, zero dependencies, and it runs on Node v18 that ships with Ubuntu 22.04. I could almost hear my VPS cheering.
Why I love this approach
- Instant bootstrap.
node server.js
and weâre live. - Zero cognitive overhead. The entire app fits on one screen.
- Diskâspace friendly. The
node_modules
folder is literally nonâexistent. - Oldâschool debugging. Add a
console.log
, refresh, done.
Why it keeps me up at night
- Manual everything. CORS, validation, DIâif I want it, I write it.
- Testing pain. Jest +Â supertest works, but all the wiring is on me.
- Callback relapse. One async slip and weâre in callbackâhell nostalgia.
- Team onboarding. Future devs might cry or quit (or both).
Hereâs what a âquickâ validation patch looked like by Sunday midnight:
if (!order.email || !/^[\w+.]+@\w+\.\w+$/.test(order.email)) {
res.writeHead(422);
return res.end('invalid email bro');
}
Multiply that by every field in the payload and you start missing those NestJS DTOs.
Route 2 â The NestJS Rollercoaster
According to the NestJSÂ 11 documentation released last month, the platform delivers âstructure without the painâ for each version since version 6. The framework adopts a stringent structure by default while utilizing TypeScript by default while demanding SOLID pattern adoption despite your real feelings of pattern neutrality. The catch? Node v20 has become the required minimum version according to their documentation, so I had to replace my comfortable Debian 10 droplet.
The famous fiveâminute start looked like this:
npm i -g @nestjs/cli
nest new skatedecks-api
cd skatedecks-api
npm run start:dev
Then I scaffolded the decks module:
nest g resource decks
Boomâcontroller, service, DTOs, tests, e2e spec, an OpenAPI stub, and a partridge in a pear tree.
A slice of the generated controller
// decks.controller.ts
@Controller('decks')
export class DecksController {
constructor(private readonly svc: DecksService) {}
@Get()
findAll() { return this.svc.all(); }
@Post()
create(@Body() dto: CreateDeckDto) {
return this.svc.add(dto);
}
}
The good stuff
- Type safety. Muscleâmemory
order.
autocomplete instead of guessing keys. - Dependency injection. Swap
DecksService
for a mock in one lineâtests love you. - Batteries included. Swagger doc, classâvalidator, caching, WebSocketsâall one
import
. - Ecosystem. I found a Stripe module in 13Â seconds.
The wallet ache
- Cold start.
npm install
pulled 164Â MB of packagesânot great for the $6Â VPS. - Learning curve. My junior dev would need a full day to grok modules vs. providers.
- Boilerplate. Every file opens with four decorators and a dozen imports.
- Performance tax. In HelloâWorld benchmarks, NestJS adds ~10Â ms to P99. On big iron you donât care; on a t2.micro you do.
Detour â The Case of âJust Use Expressâ
On Day 1 a buddy dropped into Slack and went, âDude, why are you tormenting yourself with raw Node? Just npm i express
, done.â Express sits between vanilla JS and fullâblown frameworks, so I gave it a fair trial. Spoiler: it solved 20 % of my gripes but introduced others.
import express from 'express';
const app = express().use(express.json());
app.get('/decks', (req, res) => res.json(DB.decks));
app.post('/orders', (req, res) => {
const order = req.body;
// still writing manual validation...
});
app.listen(3000);
Express saved me maybe 30 lines, but I was still stitching middlewares, handârolling Swagger, and dreaming about decorators. It felt like buying furniture from IKEAâcheaper than custom carpentry, but youâre still the one with an Allen key at 11 PM.
Benchmark Rabbit Hole
Because Iâm a numbers nerd, I spun up three DigitalOcean droplets (each 1Â vCPU, 1Â GBÂ RAM) and hammered them with wrk:
wrk -t4 -c200 -d30s http://myip:3000/decks
Stack |
Req/sec |
P99 Latency |
---|---|---|
Vanilla HTTP |
12Â 560 |
14Â ms |
Express 5 beta |
11Â 830 |
16Â ms |
NestJSÂ 11Â (Fastify) |
11Â 310 |
25Â ms |
TL;DR: the difference is peanuts compared to Stripeâs 300Â ms average. If performance is your only yardstick, choose whichever stack lets you ship faster.
âCanât I Just Add TypeScript to Vanilla?â
Absolutelyâand I tried. But the moment you add ts-node
, tsconfig.json
, a build script, and types for Node, youâre already halfway up the framework mountain. NestJS just finishes the climb and throws in rope and a granola bar.
Hidden Costs I Almost Missed
- DevOps friction. My handârolled server needed a dozen custom health checks for Kubernetes. NestJS offers a readyâmade health module.
- Monitoring. NestJSÂ +Â
@nestjs/terminus
+Â Prometheus exporter took 10Â min. Rolling my own metrics exporter would be âfunâ but billable. - Security headers. One line with
helmet()
vs. a night with OWASP docs. - Community answers. StackOverflow has 53 k questions tagged NestJS. Handârolled patterns usually = âyouâre on your own, buddy.â
These soft costs rarely show up in spreadsheets, but they bite you at 2 AM.
Costing the Two Paths (Round 1)
I grabbed a napkin, a pen, and my highly scientific âdeveloperâhour Ă coffeeâ formula.
Item |
Vanilla JS |
NestJS |
---|---|---|
Initial setup |
2Â h |
5Â h |
Basic CRUD & validation |
6Â h |
4Â h |
Tests & docs |
5Â h |
3Â h |
Containerization |
1Â h |
1Â h |
Total |
14Â h |
13Â h |
Surprise: NestJS actually wins by an hour once you factor validation and docs. The time I saved not handârolling OpenAPI wiped out the heavier bootstrap.
Hourly rate being $60 (hey, itâs a friend project), thatâs $840 vs. $780. Not a dealâbreaker, but when the budget cap is $1 000, fifty bucks buys better coffee beans.
What If the Budget Was $10Â 000?
Interestingly, the decision matrix flips. With more money Iâd consider going even simpler (e.g., Cloudflare Workers or Firebase) to avoid running servers at all. The real enemy isnât frameworksâitâs YAGNI: building things youâll never need.
But in a $1Â 000 universe, selfâhosted VPS plus a sensible framework hits the sweet spot between cost and control.
A Quick Checklist for Your Own Decision
- <Â 50Â LOC and never grows? â Raw Node.
- Needs Swagger, validation, & a team? â NestJS.
- Edge function serving static JSON? â Skip Node, use serverless.
- You hate decorators with a fiery passion? â Express + Zod.
- Management wants âenterpriseyâ buzzwords? â NestJS wins PR points.
Stick this list on your monitor; thank me later.
The Hybrid Compromise Nobody Talks About
Hereâs the secret third option: start in NestJS but treat it like a microâkernel.
-
Use only the core: controllers, services, pipes.
-
Skip fancy modules until needed.
-
Replace Fastify adapter if performance hurts later.
-
Keep feature flags to eject back to vanilla JS for specific hot paths.
I actually wrote one tiny performanceâcritical route in raw Node and mounted it under /legacy/*
:
import { createServer } from 'node:http';
// legacy.ts
export function mount(app: INestApplication) {
const server = createServer((req, res) => {
// crazy optimized CSV streaming
});
app.use('/legacy', (req, res) => server.emit('request', req, res));
}
It felt dirtyâbut it worked, and nobody on Reddit yelled at me.
A Little Easter Egg
Because Hackernoon loves code đ, hereâs the cheapest possible NestJS guard I wrote to rateâlimit the orders endpoint without introducing Redis:
@Injectable()
export class SimpleThrottleGuard implements CanActivate {
private readonly hits = new Map<string, number>();
canActivate(ctx: ExecutionContext): boolean {
const ip = ctx.switchToHttp().getRequest().ip;
const now = Date.now();
const count = (this.hits.get(ip) ?? 0) + 1;
this.hits.set(ip, count);
setTimeout(() => this.hits.set(ip, count - 1), 30_000); // decay in 30Â s
return count < 10; // 10 hits per 30Â s per IP
}
}
Yes, itâs inâmemory and will reset on pod restart, but for tiny budgets elegance often loses to pragmatism.
Spoiler: What I Finally Picked
After two weekends, four espressos, and a delicate negotiation with the VPS gods, I went with NestJS. The deciding factors were:
-
Free Swagger docs impressed the client without extra code.
-
classâvalidator
saved ~300Â lines of manual checks. -
Onboarding the cousin dev is easier with consistent patterns.
-
Docker image size barely changed after multiâstage build tricks.
Hereâs the final Dockerfile slice that kept the image at 140Â MB:
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/main.js"]
The server now idles at 29Â MBÂ RAM, handles ~400Â req/s on wrk, and logs pretty JSON. Most importantly, I sleep better knowing futureâme wonât invent a routing convention at 2Â AM because âwhy not.â
Tiny Lessons You Can Steal
- Measure first. Benchmarks killed my fear of NestJS latency.
- Hide boilerplate. Generate templates once; point juniors to
src/examples
. - Donât marry a framework. Itâs OK to break out raw Node for edge cases.
- Budget â  tech stack. The cheapest code is the code you only write once.
Outro â Ship First, Refactor Less
Each stack that leads genuine users to engage physically with actual skateboards represents the flawless stack choice. The selection of my next side-project can take a different direction, which is perfectly acceptable. Digital stacks function like tools that do not become permanent tattoos.
Purchasing me a coffee in Yerevan will show appreciation for my decision paralysis guidance. You will spot me in the corner zone working on pipe transformation from the guard structure as the fig plant agrees with silent gestures.