← Back to blog

Node.js for Backend Development: A Practical Guide

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:

  1. 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.
  2. 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.
  3. 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

AspectNode.jsPHPPython
Best forAPIs, real-time apps, microservicesContent websites, WordPress, legacy systemsData science, ML, scripts, Django/Flask APIs
Concurrency modelEvent loop (non-blocking)Process per request (traditional)Threads/async (varies by framework)
Learning curveModerate (async patterns)Easy (for basics)Easy to moderate
HostingVPS, PaaS, containersShared hosting, VPS, everywhereVPS, PaaS, containers
Package managernpm (1.5M+ packages)Composerpip (400K+ packages)
Type safetyTypeScript (optional)Weak typing (PHP 8 improved)Type hints (optional)
Typical use in CHStartups, agencies, modern stacksTraditional web, WordPress sitesFinance, 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 audit regularly. 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 .env files 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:

  1. Build a simple REST API with Express and an in-memory array (no database). Get comfortable with routes, middleware, and JSON responses.
  2. Add PostgreSQL with the pg package. Learn connection pooling and parameterized queries.
  3. Add authentication with JWT and bcrypt.
  4. Add input validation with express-validator.
  5. Add error handling middleware.
  6. Write tests with Jest and Supertest.
  7. 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

Quick Contact