Node.js

Express.js Basics

15 Questions

Express.js is a minimal and flexible Node.js web framework that provides a robust set of features for building web and API applications without imposing a strict structure. It simplifies common server tasks like routing, middleware management, and HTTP request/response handling while remaining unopinionated — leaving architectural decisions to the developer. Its simplicity, massive middleware ecosystem, and extensive community support have made it the de facto standard for Node.js web servers since 2010.
const express = require('express');
const app = express();

app.use(express.json()); // parse JSON bodies

app.get('/', (req, res) => {
  res.json({ message: 'Hello World' });
});

app.listen(3000, () => console.log('Server running on port 3000'));

Why it matters: Express is used in the majority of Node.js backend projects, and understanding it is a baseline requirement for virtually every Node.js backend interview. Its middleware architecture also forms the conceptual foundation for frameworks like Koa, Fastify, and NestJS.

Real applications: GitHub, Uber, and Twitter have used Express for their Node.js services. NestJS and LoopBack are enterprise frameworks built on top of Express, extending it with structured patterns for large teams.

Common mistakes: Starting a project without organizing routes and controllers (dumping everything into one file), which creates unmanageable spaghetti code. Express's flexibility is a feature, but it requires deliberate architecture on the developer's part.

Routes are defined using HTTP method functions on the app object (app.get, app.post, etc.), matching a path pattern to a handler function. Route parameters are defined using colon syntax (:id) and accessed through req.params, while optional parameters use :id?. Routes are matched in the order they are defined, so more specific routes must be placed before generic catch-all routes.
app.get('/users', (req, res) => res.json(users));
app.post('/users', (req, res) => { /* create user */ });
app.put('/users/:id', (req, res) => { /* update user */ });
app.delete('/users/:id', (req, res) => { /* delete user */ });

// Route chaining with app.route()
app.route('/products/:id')
  .get((req, res) => res.json(product))
  .put((req, res) => { /* update */ })
  .delete((req, res) => { /* delete */ });

Why it matters: Routes are the foundation of any Express API. Interviewers expect you to know how to create RESTful routes, use parameters, and organize routes with Express Router for scalable codebases.

Real applications: A REST API for an e-commerce app might have /products, /products/:id, /categories/:id/products — using route parameters and method-based routing to map HTTP verbs to CRUD operations on resources.

Common mistakes: Placing a generic route like app.get('/users/:id', ...) before a specific one like app.get('/users/stats', ...) — Express matches top-to-bottom, so stats would be treated as an :id parameter and the stats handler never runs.

These three objects each extract data from different parts of an HTTP request. req.params captures URL path segment values defined with :name syntax. req.query parses the query string (after the ?) as key-value pairs. req.body contains the parsed request payload (JSON, form data, etc.) but requires a body-parsing middleware to be registered first.
// GET /users/42?sort=name&page=2
app.get('/users/:id', (req, res) => {
  console.log(req.params.id);    // '42' — URL path segment
  console.log(req.query.sort);   // 'name' — query string
  console.log(req.query.page);   // '2' — query string
});

// POST /users with body { "name": "Alice", "email": "..." }
app.post('/users', express.json(), (req, res) => {
  console.log(req.body.name);    // 'Alice' — request body
  console.log(req.body.email);   // '...' — request body
});

Why it matters: Choosing the wrong source for data is a common beginner error. Route params are for resource identity, query strings for filtering/sorting/pagination, and body for creation/update payloads — knowing the distinction shows API design understanding.

Real applications: A search API like GET /products?category=electronics&sort=price&page=2 uses req.query for all search parameters. A PUT /products/:id uses req.params.id to identify the resource and req.body for the update data.

Common mistakes: Forgetting to add express.json() middleware before trying to read req.body — it will be undefined without a body parser, which is a very common and confusing "bug" for Express beginners.

Use the built-in express.static() middleware to serve files like images, CSS, and JavaScript from a designated directory, handling Content-Type headers automatically. Files are resolved relative to the directory from which Node.js is launched — use path.join(__dirname, 'public') for absolute paths to avoid path issues. Multiple static directories can be configured and are searched in the order they are registered.
const path = require('path');

// Serve all files in 'public' folder
app.use(express.static(path.join(__dirname, 'public')));

// With a virtual path prefix
app.use('/assets', express.static(path.join(__dirname, 'public')));

// Multiple directories (searched in order)
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.static(path.join(__dirname, 'uploads')));

Why it matters: Serving static assets correctly is fundamental for full-stack apps. Using absolute paths prevents "file not found" errors when the server is started from a different directory, which is a common deployment issue.

Real applications: Single-page applications built with React or Vue serve their built dist/ folder using express.static() with a fallback route that serves index.html for all unknown paths to enable client-side routing.

Common mistakes: Using a relative path like express.static('public') which breaks when the app is started from a different directory. Always use path.join(__dirname, 'public') for reliable path resolution regardless of where the process is launched from.

express.Router() creates modular, mountable route handlers that act as mini-applications with their own middleware stack and route definitions. Routers help organize large applications by grouping related routes into separate files, then mounting them under a common path prefix in the main app. Each router is a complete middleware and routing system — often called a "mini-app" within the larger application.
// routes/users.js
const router = require('express').Router();

router.use(authenticate); // middleware for all user routes

router.get('/', (req, res) => res.json(users));
router.post('/', (req, res) => { /* create */ });
router.get('/:id', (req, res) => res.json(user));
router.delete('/:id', (req, res) => { /* delete */ });

module.exports = router;

// app.js
app.use('/api/users', require('./routes/users'));

Why it matters: Express Router is the standard way to structure scalable Express applications. Interviewers expect you to know how to separate route concerns, apply route-specific middleware, and mount routes at proper prefixes.

Real applications: A production API with resources for users, products, orders, and payments uses separate router files for each, keeping each file focused and under 200 lines while the main app.js stays clean and minimal.

Common mistakes: Not using Router and instead registering all routes directly on the app object, resulting in a single massive file. Also forgetting that Router middleware is scoped — middleware added inside a router doesn't apply to routes outside it.

Express provides built-in middleware for parsing incoming request bodies: express.json() for JSON payloads and express.urlencoded() for HTML form data. Since Express 4.16+, these are built directly into Express — no longer requiring the separate body-parser package. These middleware functions must be registered before route handlers that need to access req.body, and for file uploads, multer handles multipart/form-data.
// Parse JSON bodies (Content-Type: application/json)
app.use(express.json({ limit: '10mb' }));

// Parse HTML form submissions (Content-Type: application/x-www-form-urlencoded)
app.use(express.urlencoded({ extended: true }));

app.post('/submit', (req, res) => {
  console.log(req.body); // { name: 'Alice', email: '...' }
  res.json({ received: req.body });
});

// File uploads — use multer separately
const upload = require('multer')({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
  res.json({ file: req.file });
});

Why it matters: Properly parsing request bodies is fundamental to any API. Understanding the different content types and their corresponding parsers prevents the common "req.body is undefined" bug that trips up many Express beginners.

Real applications: Registration forms send application/x-www-form-urlencoded data requiring urlencoded() middleware, while REST API clients send JSON requiring json(). Most production APIs support both to accommodate different client types.

Common mistakes: Setting an extremely high body size limit (e.g., limit: '999mb') which makes the server vulnerable to denial-of-service attacks via large request bodies. Always set a reasonable limit based on expected payload sizes.

Use res.status() to set HTTP status codes and res.set() to set custom response headers — both are chainable, allowing you to set status and send the response in a single expression. Express convenience methods like res.json() and res.send() automatically set the appropriate Content-Type header. For redirects, res.redirect() defaults to 302 but accepts a status code as the first argument.
// Chain status + body
app.post('/users', (req, res) => {
  res.status(201).json({ id: 1, name: 'Alice' });
});

// Custom headers
app.get('/data', (req, res) => {
  res
    .set('Cache-Control', 'public, max-age=3600')
    .set('X-Rate-Limit-Remaining', '99')
    .json({ data: [] });
});

app.get('/old-page', (req, res) => {
  res.redirect(301, '/new-page'); // permanent redirect
});

Why it matters: Using correct HTTP status codes is crucial for API consumers to handle responses properly. A 200 status on an error confuses clients, caches, and monitoring tools. Interviewers expect you to know standard REST status code semantics.

Real applications: REST API standards dictate: 201 for created resources, 204 for successful deletions, 400 for validation errors, 401 for unauthenticated, 403 for unauthorized, and 404 for not found — each enabling specific client behavior.

Common mistakes: Returning 200 for all responses including errors ({ status: 'error', message: '...' } with 200) which breaks HTTP semantics and makes monitoring, caching, and error tracking unreliable across the entire system.

app.use() mounts middleware functions that execute for every incoming request matching the specified path — if no path is provided, it runs for all requests. Unlike route-specific methods (app.get, app.post), app.use() uses prefix matching — app.use('/api', fn) matches /api, /api/users, and any path starting with /api. Middleware registered with app.use() runs in the exact order it is defined.
// Runs for ALL requests (no path = all)
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
  next();
});

// Runs only for /api/* routes (prefix match)
app.use('/api', authenticate);  // applies auth to all API routes

// Mount a Router at a path prefix
app.use('/api/users', userRouter);
app.use('/api/products', productRouter);

Why it matters: app.use() is the core mechanism for attaching middleware in Express. Understanding its prefix-matching behavior and call order is essential for correctly implementing middleware chains, authentication guards, and route mounting.

Real applications: A typical Express API uses app.use() to apply body parsing globally, rate limiting to /api routes, authentication to /api/protected routes, and routers to resource-specific prefixes — in precisely that order.

Common mistakes: Forgetting that app.use('/api', router) uses prefix matching not exact matching — this is intentional and correct for routers. Also forgetting to call next() in custom middleware which causes the request to hang without sending a response.

Express supports template engines like EJS, Pug, and Handlebars for server-side HTML rendering by setting the engine with app.set('view engine', ...) and specifying the views directory. Template engines allow embedding dynamic data into HTML before sending to the client — the engine renders the template with provided data and Express sends the resulting HTML. Install the engine via npm and Express automatically calls its render function when you use res.render().
// Install: npm install ejs
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// views/users.ejs:
// <h1>Users</h1>
// <% users.forEach(u => { %>
//   <p><%= u.name %></p>
// <% }) %>

app.get('/users', async (req, res) => {
  const users = await db.getUsers();
  res.render('users', { users, title: 'User List' });
});

Why it matters: Template engines are still used in server-rendered MVC applications and admin panels. Understanding them distinguishes full-stack developers who can build both APIs and server-rendered pages.

Real applications: Admin dashboards, email templates, and SEO-critical marketing pages often use server-side rendering with EJS or Pug, delivering fully rendered HTML to improve search engine indexability and initial page load performance.

Common mistakes: Not escaping user data in templates — EJS's <%= %> auto-escapes HTML but <%- %> outputs raw HTML. Using the raw syntax with user input creates XSS vulnerabilities where attackers inject malicious scripts into your page.

Use the cors middleware package to enable Cross-Origin Resource Sharing, which tells browsers which cross-origin requests are permitted through response headers. Without proper CORS configuration, browsers block all cross-origin requests from your frontend to your backend API. For production, always specify exact allowed origins instead of using * to prevent unauthorized cross-origin access from malicious sites.
const cors = require('cors');

// Allow all origins (NOT for production APIs with credentials)
app.use(cors());

// Production: restrict to specific origins
app.use(cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true // allow cookies/auth headers
}));

// Per-route CORS (e.g., public endpoints)
app.get('/public-data', cors(), (req, res) => { ... });

Why it matters: CORS is a mandatory consideration for any API consumed by a browser. Getting it wrong either blocks all legitimate requests or opens the API to cross-origin attacks. It's tested in interviews to verify you understand browser security fundamentals.

Real applications: A backend API at api.myapp.com accessed by a React frontend at app.myapp.com must configure CORS to allow that specific origin with credentials for cookie-based authentication to work correctly.

Common mistakes: Using origin: '*' with credentials: true — this combination is rejected by browsers entirely. Credentials require a specific origin, not a wildcard. Also forgetting to handle preflight OPTIONS requests for custom headers.

Express handles 404 errors by adding a catch-all middleware after all route definitions that fires when no route matches. Error-handling middleware requires exactly four parameters (err, req, res, next) — Express uses the function signature to identify it as an error handler. This pattern centralizes all unhandled requests and thrown errors into clean, consistent response formatting.
// Regular routes first
app.use('/api/users', userRouter);

// 404 handler — placed after ALL routes
app.use((req, res) => {
  res.status(404).json({ error: `Route ${req.method} ${req.url} not found` });
});

// Error handler — MUST have 4 parameters (err first)
app.use((err, req, res, next) => {
  console.error(err.stack);
  const status = err.status || err.statusCode || 500;
  res.status(status).json({
    error: process.env.NODE_ENV === 'production' ? 'Internal error' : err.message
  });
});

Why it matters: Proper 404 and error handling is essential for production APIs. Interviewers look for structural correctness (404 before error handler), correct signatures, and security awareness (hiding internal error details in production).

Real applications: Production APIs use a centralized error handler that maps error types to status codes (ValidationError → 400, AuthError → 401, NotFoundError → 404) and logs stack traces to monitoring services like Sentry without exposing them to clients.

Common mistakes: Defining the error handler before routes (it only catches errors from handlers defined before it), or giving the error handler only 3 parameters which makes Express treat it as a regular middleware and never invoke it for errors.

These methods end the response cycle but handle the body and headers differently. res.send() is the most versatile — it auto-detects Content-Type based on the argument type (string → HTML, object → JSON, Buffer → binary). res.json() always serializes to JSON and explicitly sets Content-Type: application/json, handling edge cases like undefined values. res.end() is the Node.js low-level method that sends no content body.
// res.send() — detects Content-Type automatically
res.send('Hello');              // Content-Type: text/html
res.send({ name: 'Alice' });   // Content-Type: application/json
res.send(Buffer.from('data')); // Content-Type: application/octet-stream

// res.json() — always JSON, handles special values
res.json({ name: 'Alice' });
res.json(null);                // sends "null" (valid JSON)
res.status(200).json([]);      // empty array

// res.end() — no body, minimal overhead
res.status(204).end(); // typical for DELETE success

Why it matters: Using the right response method ensures correct Content-Type headers. This affects how clients parse responses and how HTTP caches and proxies handle them. res.json() is almost always preferred for API responses.

Real applications: REST APIs exclusively use res.json() for all responses. res.send() is used for HTML rendering (when not using a template engine) and res.end() for no-content responses like successful DELETE operations (204).

Common mistakes: Using res.send() for API responses and accidentally sending a string instead of JSON — it sets Content-Type to text/html instead of application/json, confusing clients that expect JSON and causing parsing errors.

Route versioning uses Express Router instances mounted under version-specific prefixes to maintain multiple API versions simultaneously. This keeps different versions organized in separate modules, enables gradual deprecation, and ensures existing clients aren't broken when you ship breaking changes. It is the most widely adopted pattern for backward-compatible API evolution in production systems.
// routes/v1/users.js
const v1 = require('express').Router();
v1.get('/users', (req, res) => res.json({ version: 1, users }));
module.exports = v1;

// routes/v2/users.js
const v2 = require('express').Router();
v2.get('/users', (req, res) => res.json({ version: 2, users, meta: { total } }));
module.exports = v2;

// app.js — mount versioned routers
app.use('/api/v1', require('./routes/v1/users'));
app.use('/api/v2', require('./routes/v2/users'));

Why it matters: API versioning is critical for maintaining backward compatibility in production systems with multiple clients. Interviewers ask this to assess whether you understand how to evolve APIs without breaking existing consumers.

Real applications: Stripe and Twilio maintain versioned APIs where clients specify the API version in the URL. This allows shipping breaking changes to v2 while v1 clients continue working until they migrate.

Common mistakes: Adding new fields to existing API responses without versioning, assuming it's "backward compatible" — removing fields or changing types in existing versions breaks all clients relying on that structure immediately and without warning.

Graceful shutdown ensures all in-flight requests complete before the server closes, preventing data corruption and broken responses during deployments or restarts. Listen for SIGTERM (Kubernetes/Docker stop signal) and SIGINT (Ctrl+C) to trigger the shutdown sequence. server.close() stops accepting new connections but allows existing connections to complete — adding a forced timeout prevents hanging indefinitely if some requests take too long.
const server = app.listen(3000, () => console.log('Running'));

async function gracefulShutdown(signal) {
  console.log(`${signal} received — shutting down...`);

  // Stop accepting new connections
  server.close(async () => {
    await db.disconnect();  // close DB connections
    await cache.quit();     // close Redis connections
    console.log('Clean shutdown complete');
    process.exit(0);
  });

  // Force exit after 15 seconds
  setTimeout(() => process.exit(1), 15_000);
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

Why it matters: Graceful shutdown is a production requirement for zero-downtime deployments. Without it, rolling deployments can drop active requests when old instances are killed while handling in-flight traffic.

Real applications: Kubernetes rolling deployments send SIGTERM to pods being replaced. A graceful handler lets the pod finish serving its current requests (within the termination grace period) before shutting down, ensuring users never see failed requests during deploys.

Common mistakes: Calling process.exit(0) immediately on SIGTERM without draining connections, and not setting a forced-exit timeout — if a long-running WebSocket connection holds open, the server would never shut down without the timeout fallback.

Express application settings are configured with app.set(key, value) and retrieved with app.get(key), controlling framework behavior like view rendering, JSON formatting, proxy trust, and routing case sensitivity. Settings are divided into built-in Express configurations and custom application settings — a simple mechanism for storing app-wide configuration accessible throughout the request cycle.
// View rendering settings
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// JSON response formatting
app.set('json spaces', 2);      // Pretty-print in development

// Security and proxy settings
app.set('trust proxy', 1);      // Trust first proxy (nginx)
app.disable('x-powered-by');    // Don't reveal Express version

// Check settings
console.log(app.get('env'));    // 'development' or 'production'
console.log(app.enabled('trust proxy')); // true

Why it matters: Application settings affect security, performance, and correct behavior behind proxies. Disabling x-powered-by is a basic hardening step, and correct trust proxy configuration is essential for accurate client IP logging.

Real applications: Production apps behind AWS ALB or Nginx reverse proxies must set trust proxy to correctly read client IPs from X-Forwarded-For headers. Without it, rate limiters and IP-based fraud detection see the proxy IP instead of the real client.

Common mistakes: Setting trust proxy to true in apps directly facing the internet (not behind a proxy) — this allows attackers to spoof their IP address by setting the X-Forwarded-For header to anything, bypassing IP-based rate limiting.