Node.js brought JavaScript to the server in 2009 and never looked back. Today it powers Netflix, LinkedIn, Uber, PayPal, and millions of APIs worldwide. Its non-blocking event loop handles tens of thousands of concurrent connections on a single thread — and with the npm ecosystem (2M+ packages), you can build almost anything. This roadmap shows you exactly what to learn, in what order, and realistic timelines to go from zero to a job-ready Node.js developer.
At a glance
| Phase | Topics | Time estimate |
|---|---|---|
| 1 | JavaScript fundamentals | 4–6 weeks |
| 2 | Node.js core — modules, event loop, streams | 3–4 weeks |
| 3 | Async programming — callbacks, Promises, async/await | 2–3 weeks |
| 4 | Express.js and REST APIs | 4–5 weeks |
| 5 | Databases — SQL and NoSQL | 4–5 weeks |
| 6 | Authentication and security | 2–3 weeks |
| 7 | Testing — unit, integration, e2e | 2–3 weeks |
| 8 | DevOps — Docker, CI/CD, cloud deployment | 3–4 weeks |
| 9 | Advanced patterns — microservices, queues, real-time | 4–6 weeks |
| 10 | Portfolio projects and job search | 4–8 weeks |
| Total to first job | ~10–14 months |
Phase 1 — JavaScript fundamentals (Weeks 1–6)
Node.js is JavaScript on the server. A shaky JS foundation will slow you down at every phase. Invest the time upfront.
Variables and types
// var (function-scoped, avoid) — let (block-scoped) — const (block-scoped, prefer)
const name = "Alice";
let age = 30;
// JavaScript types
typeof "hello" // "string"
typeof 42 // "number"
typeof true // "boolean"
typeof undefined // "undefined"
typeof null // "object" (legacy bug)
typeof {} // "object"
typeof [] // "object"
typeof function(){} // "function"
// Type coercion gotchas — use === always
0 == false // true (loose)
0 === false // false (strict)
Functions — 3 styles
// Function declaration (hoisted)
function greet(name) {
return `Hello, ${name}!`;
}
// Function expression
const greet2 = function(name) {
return `Hello, ${name}!`;
};
// Arrow function (no own `this`)
const greet3 = (name) => `Hello, ${name}!`;
// Default parameters, rest, spread
const sum = (a, b = 0, ...rest) => a + b + rest.reduce((acc, n) => acc + n, 0);
sum(1, 2, 3, 4); // 10
Arrays and objects
// Array methods you must know
const nums = [3, 1, 4, 1, 5, 9];
nums.map(n => n * 2); // [6,2,8,2,10,18] — transform
nums.filter(n => n > 3); // [4,5,9] — keep matching
nums.reduce((acc, n) => acc + n, 0); // 23 — fold
nums.find(n => n > 4); // 5 — first match
nums.some(n => n > 8); // true
nums.every(n => n > 0); // true
nums.sort((a, b) => a - b); // sort in-place [1,1,3,4,5,9]
// Object destructuring and spread
const user = { name: "Alice", age: 30, role: "admin" };
const { name, ...rest } = user; // rest = { age: 30, role: "admin" }
const updated = { ...user, age: 31 }; // immutable update
Essential JS concepts for Node.js
| Concept | What to know |
|---|---|
| Closures | Functions that capture outer scope — used in callbacks, modules |
| Prototypal inheritance | Object.create, __proto__, class sugar |
this binding |
call/apply/bind, arrow functions fix this |
| Event loop | Call stack, microtask queue, macrotask queue |
| Modules | CommonJS (require) vs ESM (import) |
| Error handling | try/catch, error-first callbacks, Promise .catch() |
| Destructuring | Arrays, objects, function params |
| Template literals | Multi-line strings, tagged templates |
| Optional chaining | user?.address?.city |
| Nullish coalescing | config.timeout ?? 5000 |
Phase 2 — Node.js core (Weeks 7–10)
The event loop — how Node.js handles concurrency
Node.js uses a single thread with a non-blocking event loop backed by libuv. Understanding this is the single most important thing about Node.js.
┌─────────────────────────────────────────────┐
│ Call Stack (V8) │
│ (synchronous code executes here) │
└──────────────┬──────────────────────────────┘
│ when empty, check:
▼
┌─────────────────────────────────────────────┐
│ Microtask Queue (high priority) │
│ Promise .then() callbacks, queueMicrotask() │
└──────────────┬──────────────────────────────┘
│ when empty, check:
▼
┌─────────────────────────────────────────────┐
│ Event Loop Phases (libuv) │
│ timers → I/O → setImmediate → close events │
└─────────────────────────────────────────────┘
console.log("1 — sync");
setTimeout(() => console.log("4 — macro"), 0);
Promise.resolve().then(() => console.log("3 — micro"));
console.log("2 — sync");
// Output: 1, 2, 3, 4
Built-in modules you must know
const fs = require("fs"); // file system
const path = require("path"); // path manipulation
const os = require("os"); // OS info
const http = require("http"); // raw HTTP server
const https = require("https"); // HTTPS
const events = require("events"); // EventEmitter
const stream = require("stream"); // streams
const crypto = require("crypto"); // hashing, encryption
const url = require("url"); // URL parsing
const util = require("util"); // promisify, inspect
const child_process = require("child_process"); // shell commands
const cluster = require("cluster"); // multi-process
const worker_threads = require("worker_threads"); // threads for CPU work
File system operations
const fs = require("fs/promises"); // modern async API
const path = require("path");
async function processConfig() {
// Read file
const raw = await fs.readFile(path.join(__dirname, "config.json"), "utf8");
const config = JSON.parse(raw);
// Write file
config.lastUpdated = new Date().toISOString();
await fs.writeFile("config.json", JSON.stringify(config, null, 2));
// List directory
const files = await fs.readdir("./src");
console.log(files);
// Check existence
try {
await fs.access("./uploads");
} catch {
await fs.mkdir("./uploads", { recursive: true });
}
}
Streams — handling large data
const fs = require("fs");
const zlib = require("zlib");
// Without streams — loads entire file into memory (bad for large files)
// fs.readFileSync("huge.log") — could crash with OutOfMemoryError
// With streams — processes chunks as they arrive
fs.createReadStream("huge.log")
.pipe(zlib.createGzip())
.pipe(fs.createWriteStream("huge.log.gz"))
.on("finish", () => console.log("Compressed!"));
// Transform stream
const { Transform } = require("stream");
const upperCase = new Transform({
transform(chunk, encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
process.stdin.pipe(upperCase).pipe(process.stdout);
CommonJS vs ESM modules
| Feature | CommonJS (require) |
ESM (import) |
|---|---|---|
| Syntax | const x = require('./x') |
import x from './x' |
| Exports | module.exports = {} |
export default {} |
| Loading | Synchronous | Asynchronous |
| Tree-shaking | No | Yes |
| Top-level await | No | Yes |
| File extension | .js |
.mjs or "type":"module" |
| Dynamic import | require() anywhere |
import() anywhere |
Phase 3 — Async programming (Weeks 11–13)
Node.js is built on async I/O. Master these three patterns.
Pattern 1 — Callbacks (legacy, but you'll see them)
const fs = require("fs");
// Error-first callback convention: (err, result)
fs.readFile("data.json", "utf8", (err, data) => {
if (err) {
console.error("Read failed:", err.message);
return;
}
console.log(JSON.parse(data));
});
// Callback hell — why Promises were invented
fs.readFile("a.txt", "utf8", (err, a) => {
fs.readFile("b.txt", "utf8", (err, b) => {
fs.readFile("c.txt", "utf8", (err, c) => {
// 3 levels deep — hard to read and handle errors
});
});
});
Pattern 2 — Promises
const fs = require("fs/promises");
// Chain (sequential)
fs.readFile("users.json", "utf8")
.then(data => JSON.parse(data))
.then(users => users.filter(u => u.active))
.then(active => console.log(active))
.catch(err => console.error(err));
// Parallel — all at once
Promise.all([
fs.readFile("a.txt", "utf8"),
fs.readFile("b.txt", "utf8"),
fs.readFile("c.txt", "utf8"),
]).then(([a, b, c]) => console.log(a, b, c));
// Race — first to settle wins
Promise.race([fetch("/api/primary"), fetch("/api/fallback")])
.then(res => res.json());
// AllSettled — all results, even failed ones
Promise.allSettled([p1, p2, p3])
.then(results => results.forEach(r => console.log(r.status)));
Pattern 3 — async/await (modern, preferred)
const fs = require("fs/promises");
async function loadUsers() {
try {
// Sequential — waits for each
const raw = await fs.readFile("users.json", "utf8");
const users = JSON.parse(raw);
// Parallel — both fire at once
const [posts, comments] = await Promise.all([
fetch("/api/posts").then(r => r.json()),
fetch("/api/comments").then(r => r.json()),
]);
return { users, posts, comments };
} catch (err) {
console.error("Failed to load data:", err.message);
throw err; // re-throw so caller knows it failed
}
}
// Top-level await (ESM modules only, Node 14.8+)
// const data = await loadUsers();
Promisify callback-based APIs
const { promisify } = require("util");
const dns = require("dns");
// Convert callback-style to Promise
const lookup = promisify(dns.lookup);
async function getIP(hostname) {
const { address } = await lookup(hostname);
return address;
}
Phase 4 — Express.js and REST APIs (Weeks 14–18)
Express is the most popular Node.js web framework. Learn it deeply — most job descriptions list it explicitly.
Minimal Express server
const express = require("express");
const app = express();
// Built-in middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.get("/", (req, res) => res.json({ message: "Hello World" }));
app.listen(3000, () => console.log("Server running on port 3000"));
CRUD REST API with a router
// routes/users.js
const express = require("express");
const router = express.Router();
let users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
// GET /users
router.get("/", (req, res) => res.json(users));
// GET /users/:id
router.get("/:id", (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: "User not found" });
res.json(user);
});
// POST /users
router.post("/", (req, res) => {
const { name } = req.body;
if (!name) return res.status(400).json({ error: "Name is required" });
const user = { id: users.length + 1, name };
users.push(user);
res.status(201).json(user);
});
// PUT /users/:id
router.put("/:id", (req, res) => {
const idx = users.findIndex(u => u.id === parseInt(req.params.id));
if (idx === -1) return res.status(404).json({ error: "Not found" });
users[idx] = { ...users[idx], ...req.body };
res.json(users[idx]);
});
// DELETE /users/:id
router.delete("/:id", (req, res) => {
users = users.filter(u => u.id !== parseInt(req.params.id));
res.status(204).send();
});
module.exports = router;
// app.js
const usersRouter = require("./routes/users");
app.use("/users", usersRouter);
Middleware pattern
// Request logger
const logger = (req, res, next) => {
console.log(`${req.method} ${req.path} — ${Date.now()}`);
next(); // MUST call next() or request hangs
};
// Error handler (4 args — must have all 4)
const errorHandler = (err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message || "Internal Server Error",
});
};
app.use(logger);
app.use("/users", usersRouter);
app.use(errorHandler); // Must be LAST
Essential Express packages
| Package | Purpose | Install |
|---|---|---|
helmet |
Security headers | npm i helmet |
cors |
CORS middleware | npm i cors |
express-rate-limit |
Rate limiting | npm i express-rate-limit |
morgan |
HTTP request logger | npm i morgan |
compression |
gzip responses | npm i compression |
express-validator |
Input validation | npm i express-validator |
multer |
File uploads | npm i multer |
dotenv |
Environment variables | npm i dotenv |
Phase 5 — Databases (Weeks 19–23)
Every backend developer must be comfortable with both SQL and NoSQL.
PostgreSQL with pg (node-postgres)
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // connection pool size
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Parameterized query — ALWAYS use this to prevent SQL injection
async function getUser(id) {
const { rows } = await pool.query(
"SELECT id, name, email FROM users WHERE id = $1",
[id]
);
return rows[0] || null;
}
// Transaction
async function transferFunds(fromId, toId, amount) {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[amount, fromId]
);
await client.query(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
[amount, toId]
);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
ORM with Prisma (recommended for new projects)
// schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
author User @relation(fields: [authorId], references: [id])
authorId Int
}
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// CRUD with Prisma
async function examples() {
// Create with nested relation
const user = await prisma.user.create({
data: {
email: "alice@example.com",
name: "Alice",
posts: { create: { title: "Hello World" } },
},
include: { posts: true },
});
// Find with filtering
const activeUsers = await prisma.user.findMany({
where: { posts: { some: {} } },
orderBy: { createdAt: "desc" },
take: 10,
skip: 0,
});
// Update
await prisma.user.update({
where: { id: 1 },
data: { name: "Alicia" },
});
// Delete
await prisma.user.delete({ where: { id: 1 } });
}
MongoDB with Mongoose
const mongoose = require("mongoose");
await mongoose.connect(process.env.MONGODB_URI);
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
role: { type: String, enum: ["user", "admin"], default: "user" },
createdAt: { type: Date, default: Date.now },
});
const User = mongoose.model("User", userSchema);
// CRUD
const user = await User.create({ name: "Alice", email: "alice@example.com" });
const users = await User.find({ role: "admin" }).limit(10).sort("-createdAt");
await User.findByIdAndUpdate(id, { name: "Alicia" }, { new: true });
await User.findByIdAndDelete(id);
Database tool ecosystem
| Tool | Type | Best for |
|---|---|---|
pg (node-postgres) |
SQL driver | PostgreSQL, raw queries |
Prisma |
ORM | Type-safe queries, migrations, DX |
Drizzle |
ORM | Lightweight, SQL-like API |
TypeORM |
ORM | Decorator-based, Java-like |
Knex.js |
Query builder | Flexible SQL without full ORM |
Mongoose |
ODM | MongoDB with schema validation |
ioredis |
Client | Redis with Promises, cluster |
elasticsearch |
Client | Elasticsearch full-text search |
Phase 6 — Authentication and security (Weeks 24–26)
JWT authentication
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const SECRET = process.env.JWT_SECRET; // must be long & random
// Register — hash password, never store plain text
async function register(email, password) {
const hash = await bcrypt.hash(password, 12); // cost factor 12
const user = await db.user.create({ email, passwordHash: hash });
return user;
}
// Login — verify credentials, issue token
async function login(email, password) {
const user = await db.user.findUnique({ where: { email } });
if (!user) throw new Error("Invalid credentials");
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) throw new Error("Invalid credentials");
const token = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
SECRET,
{ expiresIn: "7d" }
);
return { token };
}
// Auth middleware
function requireAuth(req, res, next) {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Unauthorized" });
}
try {
const payload = jwt.verify(auth.slice(7), SECRET);
req.user = payload;
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
// Protected route
app.get("/profile", requireAuth, (req, res) => {
res.json({ user: req.user });
});
Security essentials
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const cors = require("cors");
// Security headers (CSP, HSTS, X-Frame-Options, etc.)
app.use(helmet());
// CORS
app.use(cors({
origin: process.env.FRONTEND_URL,
credentials: true,
}));
// Rate limiting — 100 requests per 15 minutes per IP
app.use("/api", rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
}));
// Input validation with express-validator
const { body, validationResult } = require("express-validator");
app.post(
"/users",
[
body("email").isEmail().normalizeEmail(),
body("password").isLength({ min: 8 }).trim(),
body("name").notEmpty().escape(),
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// safe to proceed
}
);
Phase 7 — Testing (Weeks 27–29)
Unit tests with Jest
// math.js
function add(a, b) { return a + b; }
function divide(a, b) {
if (b === 0) throw new Error("Division by zero");
return a / b;
}
module.exports = { add, divide };
// math.test.js
const { add, divide } = require("./math");
describe("add", () => {
test("adds two numbers", () => expect(add(1, 2)).toBe(3));
test("handles negatives", () => expect(add(-1, 1)).toBe(0));
});
describe("divide", () => {
test("divides numbers", () => expect(divide(6, 2)).toBe(3));
test("throws on zero", () => {
expect(() => divide(1, 0)).toThrow("Division by zero");
});
});
Integration tests with Supertest
const request = require("supertest");
const app = require("./app");
describe("GET /users", () => {
test("returns list of users", async () => {
const res = await request(app)
.get("/users")
.set("Authorization", `Bearer ${TEST_TOKEN}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
test("returns 401 without token", async () => {
const res = await request(app).get("/users");
expect(res.status).toBe(401);
});
});
describe("POST /users", () => {
test("creates a user", async () => {
const res = await request(app)
.post("/users")
.send({ name: "Alice", email: "alice@test.com" });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ name: "Alice" });
});
});
Testing pyramid
| Level | Tool | Speed | Coverage goal |
|---|---|---|---|
| Unit | Jest | Fast | 70–80% |
| Integration | Jest + Supertest | Medium | API contracts |
| E2E | Playwright | Slow | Critical flows |
| Load | k6 / Artillery | Slow | Performance baseline |
Phase 8 — DevOps: Docker, CI/CD, cloud (Weeks 30–33)
Dockerfile for Node.js
# Multi-stage build — smaller final image
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # if you have a build step
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Run as non-root user
RUN addgroup -S nodejs && adduser -S nodejs -G nodejs
USER nodejs
COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package.json .
EXPOSE 3000
CMD ["node", "dist/index.js"]
docker-compose.yml for local development
services:
api:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp
- REDIS_URL=redis://redis:6379
depends_on:
db:
condition: service_healthy
volumes:
- .:/app
- /app/node_modules # keep container's node_modules
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_PASSWORD: password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5
redis:
image: redis:7-alpine
GitHub Actions CI/CD
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm test
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/testdb
- run: npm run build
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to Railway
run: railway up --service my-api
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
Cloud deployment options
| Platform | Best for | Free tier | CLI |
|---|---|---|---|
| Railway | Full-stack apps | $5 credit/mo | railway up |
| Render | Web services | 750h/mo | Git push |
| Fly.io | Global edge | 3 small VMs | fly deploy |
| Vercel | Serverless/edge | Generous | vercel --prod |
| AWS App Runner | Scalable containers | 25h/mo | apprunner |
| DigitalOcean App Platform | Simple deployments | — | doctl |
Phase 9 — Advanced patterns (Weeks 34–39)
Real-time with Socket.IO
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: { origin: process.env.FRONTEND_URL },
});
io.on("connection", (socket) => {
console.log("Client connected:", socket.id);
socket.on("join-room", (roomId) => {
socket.join(roomId);
socket.to(roomId).emit("user-joined", socket.id);
});
socket.on("send-message", ({ roomId, message }) => {
io.to(roomId).emit("new-message", { from: socket.id, message });
});
socket.on("disconnect", () => {
console.log("Client disconnected:", socket.id);
});
});
server.listen(3000);
Background jobs with BullMQ (Redis-backed)
const { Queue, Worker } = require("bullmq");
const IORedis = require("ioredis");
const connection = new IORedis(process.env.REDIS_URL);
// Producer — add jobs to queue
const emailQueue = new Queue("emails", { connection });
async function sendWelcomeEmail(userId) {
await emailQueue.add("welcome", { userId }, {
delay: 1000,
attempts: 3,
backoff: { type: "exponential", delay: 2000 },
});
}
// Worker — process jobs
const worker = new Worker("emails", async (job) => {
const { userId } = job.data;
const user = await db.user.findById(userId);
await mailer.send({
to: user.email,
subject: "Welcome!",
html: renderWelcomeTemplate(user),
});
}, { connection });
worker.on("failed", (job, err) => {
console.error(`Job ${job.id} failed:`, err.message);
});
Caching with Redis
const Redis = require("ioredis");
const redis = new Redis(process.env.REDIS_URL);
// Cache-aside pattern
async function getUser(id) {
const cacheKey = `user:${id}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Miss — fetch from DB
const user = await db.user.findById(id);
if (!user) return null;
// Store in cache with 5-minute TTL
await redis.setex(cacheKey, 300, JSON.stringify(user));
return user;
}
// Cache invalidation
async function updateUser(id, data) {
const user = await db.user.update(id, data);
await redis.del(`user:${id}`); // invalidate cache
return user;
}
Project structure for production apps
my-api/
├── src/
│ ├── index.js # Entry point — starts server
│ ├── app.js # Express app (no listen) — for testing
│ ├── config/
│ │ └── index.js # Environment variables, validation
│ ├── routes/
│ │ ├── index.js # Router aggregator
│ │ ├── users.js
│ │ └── posts.js
│ ├── controllers/
│ │ ├── users.js # Request/response handling
│ │ └── posts.js
│ ├── services/
│ │ ├── users.js # Business logic
│ │ └── email.js # External service wrappers
│ ├── models/
│ │ └── index.js # Prisma client / Mongoose models
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── validate.js
│ │ └── errorHandler.js
│ └── utils/
│ ├── logger.js # Pino or Winston
│ └── asyncHandler.js
├── tests/
│ ├── unit/
│ └── integration/
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── .env.example
├── docker-compose.yml
└── package.json
Technology map
NODE.JS ECOSYSTEM
┌─────────────────────────────────────────────────────────────┐
│ Runtime │ Node.js (LTS) · Bun · Deno │
│ Language │ JavaScript · TypeScript │
│ Web │ Express · Fastify · NestJS · Hono · Koa │
│ Databases │ PostgreSQL · MongoDB · Redis · SQLite │
│ ORMs │ Prisma · Drizzle · TypeORM · Mongoose │
│ Testing │ Jest · Vitest · Mocha · Supertest │
│ Auth │ JWT · Passport.js · Auth.js · Clerk │
│ Queues │ BullMQ · Agenda · bee-queue │
│ Real-time │ Socket.IO · ws · Server-Sent Events │
│ Validation │ Zod · Joi · express-validator · Yup │
│ Logging │ Pino · Winston · Morgan │
│ DevOps │ Docker · GitHub Actions · Railway · Fly.io │
└─────────────────────────────────────────────────────────────┘
12-month timeline
| Month | Focus | Milestone |
|---|---|---|
| 1 | JavaScript fundamentals | Build 5 JS programs |
| 2 | Node.js core + async | CLI tool that reads/writes files |
| 3 | Express basics | Todo REST API (no DB) |
| 4 | Databases (PostgreSQL + Prisma) | Persistent Todo API |
| 5 | Authentication + security | Auth system with JWT |
| 6 | Testing | 80% test coverage on your API |
| 7 | Portfolio project #1 | Job board API or chat app |
| 8 | TypeScript migration | Typed version of your API |
| 9 | Docker + CI/CD | Dockerized, auto-deploys on push |
| 10 | Advanced topics | Redis, queues, real-time |
| 11 | Portfolio project #2 | E-commerce API or SaaS backend |
| 12 | Job search | Apply, LeetCode JS medium problems |
Portfolio project ideas
| Project | Skills demonstrated | Complexity |
|---|---|---|
| REST API with full CRUD | Express, PostgreSQL, auth, testing | Beginner |
| Real-time chat | Socket.IO, Redis, JWT | Intermediate |
| File upload service | Multer, S3, streams, queues | Intermediate |
| Job queue processor | BullMQ, Redis, email, webhooks | Intermediate |
| Multi-tenant SaaS API | Row-level security, Prisma, billing | Advanced |
| GraphQL API | Apollo Server, DataLoader, auth | Advanced |
Node.js developer roles and salaries (2025)
| Role | Skills | Salary (US) |
|---|---|---|
| Junior Node.js developer | Express, REST, PostgreSQL, basic testing | $65–$90k |
| Mid Node.js developer | TypeScript, auth, Docker, CI/CD | $90–$130k |
| Senior Node.js developer | Architecture, microservices, Redis, scaling | $130–$180k |
| Full-stack developer | Node.js + React/Vue | $100–$160k |
| Node.js tech lead | Architecture, team mentoring, system design | $160–$220k |
| Staff engineer | Platform, cross-team impact | $200k+ |
Common mistakes to avoid
| Mistake | Why it's a problem | Fix |
|---|---|---|
| Blocking the event loop | fs.readFileSync, heavy computation freezes all requests |
Use async APIs; offload CPU work to worker threads |
Not using async/await properly |
Forgetting await causes undefined results |
Always await async calls; add eslint-plugin-no-floating-promises |
| Missing error handling in async routes | Unhandled rejections crash the process in Node < 15 | Wrap every async handler in asyncHandler() or use express-async-errors |
| Hardcoding secrets | .env files committed to git |
Use dotenv + .gitignore + environment secrets in CI |
| No input validation | SQL injection, XSS, crashes | Always validate with express-validator or zod |
| N+1 database queries | 100 users × 1 query each = 100 DB round-trips | Use include/join, DataLoader for batching |
| No connection pooling | Opening a new DB connection per request kills performance | Configure pg.Pool or let Prisma manage the pool |
require() inside route handlers |
Module loading on every request, slow and unreliable | Always require() at the top of the file |
Node.js vs alternatives
| Factor | Node.js | Go | Python (FastAPI) | Java (Spring Boot) |
|---|---|---|---|---|
| Language | JavaScript/TypeScript | Go | Python | Java/Kotlin |
| Performance | High (I/O bound) | Very high | Medium | High |
| Concurrency | Event loop | Goroutines | asyncio / threads | Threads / virtual threads |
| Learning curve | Low (JS already known) | Medium | Low | High |
| Ecosystem | Largest (npm 2M+) | Growing | Large (PyPI) | Mature (Maven) |
| Real-time | Excellent (Socket.IO) | Good | Good | Good |
| Startup time | Fast | Very fast | Medium | Slow |
| Best for | APIs, real-time, BFF | Cloud tools, CLIs | ML/AI, data | Enterprise, banking |
Frequently asked questions
Do I need to learn JavaScript before Node.js?
Yes — Node.js is JavaScript. Spend 4–6 weeks on JS fundamentals (ES2015+ syntax, async patterns, closures, prototypes) before touching server-side code. Rushing this phase is the #1 reason beginners stall later.
Should I use Express, Fastify, or NestJS?
Start with Express — it's simple, flexible, and ubiquitous in job descriptions. Once you understand HTTP and middleware deeply, explore Fastify (faster, TypeScript-native) or NestJS (opinionated, Angular-like structure, great for large teams). Your Express knowledge transfers completely.
Is TypeScript necessary for Node.js?
Not to get started, but yes for production work. Most job postings now expect TypeScript. Add it after you're comfortable with Node.js and Express (around month 8 in this roadmap). The investment pays back in fewer runtime bugs and better IDE support.
Node.js vs Bun — should I learn Bun?
Learn Node.js first — it has the most documentation, community support, and job listings. Bun is compatible with most Node.js code and is worth exploring once you're productive, but it's not a job requirement yet (as of 2025).
How do I handle CPU-intensive tasks in Node.js?
Node.js is single-threaded, so CPU-heavy work (image processing, cryptography, ML inference) blocks the event loop. Options: worker_threads for in-process threads, child_process.fork() for separate processes, or offload to a dedicated service (Python for ML, Go for batch processing).
What's the best way to structure a large Node.js API?
Follow a layered architecture: routes (URL mapping) → controllers (request/response) → services (business logic) → repositories/models (data access). Keep business logic out of routes and never put DB queries in controllers. This makes testing and maintenance much easier.