Why Node.js for the Backend?
Node.js lets you run JavaScript on the server. That is the one-sentence explanation. But why does that matter? Three reasons have driven its adoption since Ryan Dahl created it in 2009:
- One language everywhere. If your frontend team writes React or Vue (JavaScript), your backend team can write JavaScript too. Shared language means shared knowledge, shared utility functions, and easier hiring. We compare frontend frameworks in our React vs Vue analysis.
- Non-blocking I/O. Node.js handles thousands of simultaneous connections efficiently because it does not create a new thread for each request. This makes it excellent for real-time applications, APIs, and I/O-heavy workloads.
- npm ecosystem. The Node Package Manager gives you access to over 1.5 million packages. Need a PDF generator? An email sender? A payment gateway integration? There is a package for it. (This is also a supply chain risk, as we discussed in our article on Log4j and software dependencies.)
Node.js is used in production by Netflix, LinkedIn, PayPal, Uber, NASA, and thousands of companies from startups to enterprises. It is not a niche technology. It is a mainstream backend choice.
The Event Loop: What Makes Node.js Different
Most backend languages (PHP, Python with traditional WSGI, Java with threads) handle concurrent requests by creating a new thread or process for each one. If 1,000 users hit your server simultaneously, you need resources for 1,000 threads. Each thread sits idle while waiting for database queries, file reads, or API calls.
Node.js works differently. It runs on a single thread with an event loop. When a request comes in that needs to wait for something (a database query, a file read, an HTTP call to another service), Node.js does not block. It registers a callback and moves on to the next request. When the database query finishes, the callback fires and Node.js processes the result.
Think of it like a restaurant with one waiter who is extremely fast. Instead of standing next to table 1 waiting for their food to arrive from the kitchen, the waiter takes table 2's order, then table 3's, then delivers table 1's food when the kitchen rings the bell. One waiter, many tables, no idle waiting.
When This Model Excels
- API servers handling many concurrent requests
- Real-time applications (chat, notifications, live dashboards)
- Microservices that aggregate data from multiple sources
- Streaming applications (file uploads/downloads, video)
- Server-side rendering for frontend frameworks (Next.js, Nuxt)
When This Model Struggles
- CPU-intensive tasks (image processing, video encoding, complex calculations). These block the single thread and make everything else wait. Solutions exist (worker threads, child processes, offloading to specialized services) but they add complexity.
- Applications that require heavy computation per request are better served by Go, Rust, Java, or Python with multiprocessing.
Express.js: The Foundation of Most Node.js APIs
Express.js is a minimal web framework for Node.js. It has been the default choice for building APIs and web applications in Node.js since 2010. While newer alternatives exist (Fastify, Koa, Hono, NestJS), Express remains the most widely used and documented.
Setting Up a Basic Express Server
// server.js
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
// Parse JSON request bodies
app.use(express.json());
// A simple route
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Run this with node server.js and you have a working HTTP server. Visit http://localhost:3000/api/health and you get a JSON response. That is Express in its simplest form.
Middleware: The Express Pattern
Express is built around middleware: functions that execute in sequence for each request. Each middleware function can read the request, modify the response, or pass control to the next middleware.
// Logging middleware
app.use((req, res, next) => {
console.log(`${req.method} ${req.path} - ${new Date().toISOString()}`);
next(); // Pass to the next middleware
});
// Authentication middleware
function authenticate(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
// Verify token (simplified)
try {
req.user = verifyToken(token);
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
}
// Protected route using the auth middleware
app.get('/api/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
This middleware pattern is what makes Express flexible. You can add logging, authentication, rate limiting, CORS headers, body parsing, and error handling as composable layers.
Building a REST API: Step by Step
Let us build a simple product API to demonstrate the patterns. We will cover routes, controllers, and basic CRUD operations.
Project Structure
project/
src/
routes/
products.js
controllers/
productController.js
middleware/
auth.js
errorHandler.js
config/
database.js
app.js
server.js
package.json
This structure separates concerns: routes define endpoints, controllers handle business logic, middleware handles cross-cutting concerns, and config manages external connections.
Routes
// src/routes/products.js
const express = require('express');
const router = express.Router();
const controller = require('../controllers/productController');
const { authenticate } = require('../middleware/auth');
router.get('/', controller.getAll);
router.get('/:id', controller.getById);
router.post('/', authenticate, controller.create);
router.put('/:id', authenticate, controller.update);
router.delete('/:id', authenticate, controller.delete);
module.exports = router;
Controllers
// src/controllers/productController.js
exports.getAll = async (req, res, next) => {
try {
const { page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
const products = await db.query(
'SELECT * FROM products ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[limit, offset]
);
res.json({ data: products.rows, page: Number(page), limit: Number(limit) });
} catch (err) {
next(err); // Pass to error handling middleware
}
};
exports.getById = async (req, res, next) => {
try {
const { id } = req.params;
const result = await db.query('SELECT * FROM products WHERE id = $1', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
res.json({ data: result.rows[0] });
} catch (err) {
next(err);
}
};
Database Connections
PostgreSQL with pg
PostgreSQL is the most common database paired with Node.js in production applications. The pg package provides a straightforward connection:
// src/config/database.js
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // Maximum pool size
idleTimeoutMillis: 30000,
});
module.exports = {
query: (text, params) => pool.query(text, params),
getClient: () => pool.connect(),
};
Using a connection pool (not a single connection) is essential. The pool manages multiple connections and reuses them, which prevents your server from opening thousands of database connections under load.
MongoDB with Mongoose
If your data is document-oriented (variable schemas, nested objects, rapid iteration), MongoDB with Mongoose is popular in the Node.js ecosystem:
const mongoose = require('mongoose');
mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
const productSchema = new mongoose.Schema({
name: { type: String, required: true },
price: { type: Number, required: true },
description: String,
category: String,
createdAt: { type: Date, default: Date.now },
});
const Product = mongoose.model('Product', productSchema);
Which Database to Choose?
PostgreSQL when: you need relational data, transactions, complex queries, strong consistency. This covers the majority of business applications.
MongoDB when: your data structure varies significantly between records, you need horizontal scaling from the start, or you are prototyping rapidly and your schema is still evolving.
For most business applications built by SMEs in Switzerland, PostgreSQL is the safer default.
Authentication with JWT
JSON Web Tokens (JWT) are the standard way to handle authentication in Node.js APIs. Here is a minimal implementation:
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const SECRET = process.env.JWT_SECRET; // Must be a long random string
// Login endpoint
exports.login = async (req, res) => {
const { email, password } = req.body;
// Find user in database
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
if (!user.rows[0]) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Compare password hash
const valid = await bcrypt.compare(password, user.rows[0].password_hash);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate token
const token = jwt.sign(
{ userId: user.rows[0].id, email: user.rows[0].email },
SECRET,
{ expiresIn: '24h' }
);
res.json({ token });
};
// Middleware to verify token
exports.authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET);
req.user = decoded;
next();
} catch (err) {
res.status(401).json({ error: 'Invalid or expired token' });
}
};
Security notes: always hash passwords with bcrypt (never store plain text). Use a strong, random JWT secret (at least 256 bits). Set reasonable expiration times. Store tokens in httpOnly cookies for browser-based applications, not in localStorage (localStorage is accessible to JavaScript, which means XSS attacks can steal tokens).
Error Handling Patterns
Proper error handling separates amateur Node.js code from production-ready code. Express has a built-in mechanism for error handling middleware:
// src/middleware/errorHandler.js
function errorHandler(err, req, res, next) {
// Log the error (in production, use a proper logging service)
console.error(`[${new Date().toISOString()}] Error:`, err.message);
// Operational errors (expected, like validation failures)
if (err.isOperational) {
return res.status(err.statusCode || 400).json({
error: err.message,
});
}
// Programming errors (unexpected bugs)
// Do not leak internal details to the client
res.status(500).json({
error: 'An internal error occurred',
});
}
// Custom error class
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
module.exports = { errorHandler, AppError };
The key principle: distinguish between operational errors (invalid input, resource not found, authentication failure) and programming errors (null reference, type errors, unhandled rejections). Operational errors return meaningful messages to the client. Programming errors return a generic message and get logged for investigation.
Handling Unhandled Promise Rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
// In production: log, alert, and gracefully shut down
});
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// Graceful shutdown
process.exit(1);
});
Testing with Jest
Jest is the most popular testing framework for Node.js. It provides test running, assertions, and mocking in a single package.
// __tests__/products.test.js
const request = require('supertest');
const app = require('../src/app');
describe('GET /api/products', () => {
it('returns a list of products', async () => {
const response = await request(app)
.get('/api/products')
.expect(200);
expect(response.body.data).toBeDefined();
expect(Array.isArray(response.body.data)).toBe(true);
});
it('supports pagination', async () => {
const response = await request(app)
.get('/api/products?page=1&limit=5')
.expect(200);
expect(response.body.limit).toBe(5);
expect(response.body.page).toBe(1);
});
});
describe('POST /api/products', () => {
it('requires authentication', async () => {
await request(app)
.post('/api/products')
.send({ name: 'Test', price: 10 })
.expect(401);
});
it('creates a product with valid token', async () => {
const token = generateTestToken();
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Test Product', price: 29.90 })
.expect(201);
expect(response.body.data.name).toBe('Test Product');
});
});
Write tests for every endpoint. Test the happy path, error cases, edge cases, and authentication. Supertest makes it easy to send HTTP requests to your Express app without starting a server.
Node.js vs PHP vs Python: When to Use What
| Aspect | Node.js | PHP | Python |
|---|---|---|---|
| Best for | APIs, real-time apps, microservices | Content websites, WordPress, legacy systems | Data science, ML, scripts, Django/Flask APIs |
| Concurrency model | Event loop (non-blocking) | Process per request (traditional) | Threads/async (varies by framework) |
| Learning curve | Moderate (async patterns) | Easy (for basics) | Easy to moderate |
| Hosting | VPS, PaaS, containers | Shared hosting, VPS, everywhere | VPS, PaaS, containers |
| Package manager | npm (1.5M+ packages) | Composer | pip (400K+ packages) |
| Type safety | TypeScript (optional) | Weak typing (PHP 8 improved) | Type hints (optional) |
| Typical use in CH | Startups, agencies, modern stacks | Traditional web, WordPress sites | Finance, pharma, data, academics |
Choose Node.js When:
- Your frontend is already JavaScript (React, Vue, Angular)
- You are building a REST or GraphQL API
- You need real-time features (WebSockets, server-sent events)
- You want to share code between frontend and backend
- You are building microservices that need to handle many concurrent connections
Choose PHP When:
- You are working with WordPress, Laravel, or an existing PHP codebase
- You need cheap shared hosting (PHP runs everywhere)
- Your team knows PHP and the project does not require real-time features
Choose Python When:
- Your application involves data processing, machine learning, or scientific computing
- You are building internal tools or automation scripts alongside an API
- Your team has Python expertise and the project is data-heavy
Security Considerations for Node.js Backends
A Node.js API exposed to the internet needs several layers of protection. Here are the essentials:
Helmet.js
Helmet sets various HTTP headers to protect your app from common attacks. One line of code, significant security improvement:
const helmet = require('helmet');
app.use(helmet());
This adds Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, and other security headers that browsers respect.
Rate Limiting
Without rate limiting, an attacker can overwhelm your API with requests (brute force attacks, denial of service, data scraping):
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // max 100 requests per window per IP
message: { error: 'Too many requests, try again later' },
});
app.use('/api/', limiter);
Input Validation
Never trust user input. Validate and sanitize everything:
const { body, validationResult } = require('express-validator');
app.post('/api/products',
authenticate,
[
body('name').isString().trim().isLength({ min: 1, max: 200 }),
body('price').isFloat({ min: 0, max: 999999 }),
body('description').optional().isString().trim().escape(),
],
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
},
controller.create
);
Input validation prevents SQL injection, XSS, and other injection attacks. For a deeper understanding of XSS specifically, see our explanation of XSS attacks. The OWASP Top 10, which we cover in our OWASP Top 10 guide, lists injection as one of the most common web application risks.
Additional Security Measures
- Use parameterized queries for all database operations. Never concatenate user input into SQL strings.
- Enable CORS properly by specifying allowed origins, not using
*in production. - Keep dependencies updated. Run
npm auditregularly. Enable Dependabot or Snyk. - Use HTTPS always. In production, terminate SSL at the load balancer or reverse proxy (Nginx, Caddy).
- Store secrets in environment variables, never in code. Use
.envfiles locally and proper secret management in production.
Performance Tips
- Use compression:
app.use(compression())reduces response sizes by 60-80% for text-based responses. - Add caching headers: For data that does not change frequently, set Cache-Control headers to reduce database hits.
- Use connection pooling: Always use a pool for database connections, never a single connection.
- Run with PM2 or cluster mode: Node.js runs on a single core by default. PM2 or the cluster module runs multiple instances to use all CPU cores.
- Paginate everything: Never return unbounded result sets. Always limit and paginate database queries.
- Profile before optimizing: Use Node.js built-in profiler or clinic.js to find actual bottlenecks instead of guessing.
Getting Started: What to Build First
If you are new to Node.js backend development, here is a practical learning path:
- Build a simple REST API with Express and an in-memory array (no database). Get comfortable with routes, middleware, and JSON responses.
- Add PostgreSQL with the pg package. Learn connection pooling and parameterized queries.
- Add authentication with JWT and bcrypt.
- Add input validation with express-validator.
- Add error handling middleware.
- Write tests with Jest and Supertest.
- Deploy to a VPS or PaaS (Railway, Render, Fly.io) and set up environment variables properly.
Each step builds on the previous one. By the end, you have a production-ready pattern that you can apply to any project.
If your team needs help architecting a Node.js backend, choosing between Node.js and other technologies, or auditing an existing Node.js application for security and performance, reach out to us. We build and review backend systems for businesses across Lugano and Switzerland.
Want to know if your site is secure?
Request a free security audit. In 48 hours you get a complete report.
Request Free Audit