Episode 3 — NodeJS MongoDB Backend Architecture / 3.4 — Express JS

3.4.c — Returning Responses

In one sentence: Express's response object (res) provides a rich set of methods — send(), json(), status(), redirect(), render(), download(), and more — that let you control what the client receives, how it's formatted, and which headers accompany it, forming the backbone of every API and web server you build.

Navigation: <- 3.4.b — Setting Up Express Server | 3.4.d — Query Parameters and URL Parameters ->


1. res.send() — auto content-type detection

res.send() is the Swiss-army knife of Express responses. It auto-detects the content type based on the argument:

// String → text/html
app.get('/html', (req, res) => {
  res.send('<h1>Hello World</h1>');
  // Content-Type: text/html; charset=utf-8
});

// Object or Array → application/json
app.get('/json', (req, res) => {
  res.send({ name: 'Alice', age: 30 });
  // Content-Type: application/json; charset=utf-8
});

// Buffer → application/octet-stream
app.get('/buffer', (req, res) => {
  res.send(Buffer.from('raw bytes'));
  // Content-Type: application/octet-stream
});

// Number → DOES NOT send as body (legacy: treated as status code)
// WRONG: res.send(200) — this sets status, not body
// Use res.sendStatus(200) or res.status(200).send('OK') instead

What res.send() does internally

  1. Sets Content-Type header based on argument type (if not already set)
  2. Sets Content-Length header automatically
  3. Handles HEAD requests (sends headers only, no body)
  4. Sets ETag header for caching (auto-generated)
  5. Calls res.end() — the response is finished after send()

2. res.json() — always sends JSON

res.json() always serializes the argument with JSON.stringify() and sets Content-Type: application/json:

// Object
app.get('/api/user', (req, res) => {
  res.json({ id: 1, name: 'Alice', email: 'alice@example.com' });
});

// Array
app.get('/api/users', (req, res) => {
  res.json([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ]);
});

// Primitives — still valid JSON
app.get('/api/count', (req, res) => {
  res.json(42);           // Sends: 42
});

app.get('/api/active', (req, res) => {
  res.json(true);         // Sends: true
});

app.get('/api/nothing', (req, res) => {
  res.json(null);         // Sends: null
});

res.send() vs res.json() — which to use?

ScenarioUse
Building an API — returning datares.json() — intent is explicit
Returning HTML stringres.send()
Returning a fileres.sendFile()
Need to return null, false, or a number as JSONres.json() — handles edge cases correctly
Mixed responses in the same appBe consistent per route type

Best practice for APIs: Always use res.json(). It makes the intent clear and handles edge cases (null, booleans, numbers) that res.send() may not serialize the same way.


3. res.status(code).json({...}) — chaining status with response

res.status() sets the HTTP status code and returns res, enabling method chaining:

// 200 — OK (default, often omitted)
app.get('/api/users', (req, res) => {
  res.status(200).json({ users: [] });
  // Equivalent to: res.json({ users: [] });
});

// 201 — Created
app.post('/api/users', (req, res) => {
  const newUser = { id: Date.now(), name: req.body.name };
  res.status(201).json(newUser);
});

// 400 — Bad Request
app.post('/api/users', (req, res) => {
  if (!req.body.name) {
    return res.status(400).json({
      error: 'Validation failed',
      details: [{ field: 'name', message: 'Name is required' }],
    });
  }
  // ... create user ...
});

// 404 — Not Found
app.get('/api/users/:id', (req, res) => {
  const user = findUser(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.json(user);
});

// 500 — Internal Server Error
app.get('/api/data', (req, res) => {
  try {
    const data = riskyOperation();
    res.json(data);
  } catch (err) {
    res.status(500).json({ error: 'Something went wrong' });
  }
});

Critical: Use return before error responses to prevent code from continuing and sending a second response.


4. res.sendStatus(code) — send status code as response body

res.sendStatus() sets the status code and sends the status text as the response body:

app.delete('/api/users/:id', (req, res) => {
  // ... delete user ...
  res.sendStatus(204);
  // Status: 204, Body: "No Content"
});

app.post('/api/login', (req, res) => {
  if (!authorized) {
    return res.sendStatus(401);
    // Status: 401, Body: "Unauthorized"
  }
});
MethodStatusBody
res.sendStatus(200)200"OK"
res.sendStatus(201)201"Created"
res.sendStatus(204)204"No Content"
res.sendStatus(400)400"Bad Request"
res.sendStatus(404)404"Not Found"
res.sendStatus(500)500"Internal Server Error"

When to use: Quick utility responses where you do not need a custom body.


5. res.redirect(url) — redirecting

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

// 301 permanent redirect (SEO — search engines update their index)
app.get('/old-url', (req, res) => {
  res.redirect(301, '/new-url');
});

// Redirect to external URL
app.get('/google', (req, res) => {
  res.redirect('https://www.google.com');
});

// Redirect back to the previous page (uses Referer header)
app.post('/form-submit', (req, res) => {
  // ... process form ...
  res.redirect('back');
});

// Redirect with query parameters
app.get('/search-redirect', (req, res) => {
  res.redirect(`/results?q=${encodeURIComponent(req.query.term)}`);
});
CodeTypeWhen to use
301PermanentURL changed forever (SEO)
302Found (temporary)Default; temporary redirect
307Temporary (preserves method)POST redirect that must stay POST
308Permanent (preserves method)Permanent redirect that must stay POST

6. res.render() — rendering templates (preview)

res.render() renders a template (EJS, Pug, Handlebars) and sends the resulting HTML:

// Setup (done once)
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// Usage — renders views/home.ejs with data
app.get('/', (req, res) => {
  res.render('home', {
    title: 'Welcome',
    user: { name: 'Alice' },
  });
});

// views/home.ejs
// <h1><%= title %></h1>
// <p>Hello, <%= user.name %></p>

This is covered in detail in 3.5 — Template Engine EJS. For API-only servers, you may never use res.render().


7. res.download() — triggering file download

const path = require('path');

// Basic download — browser shows "Save As" dialog
app.get('/download/report', (req, res) => {
  res.download(path.join(__dirname, 'files', 'report.pdf'));
  // Sets Content-Disposition: attachment; filename="report.pdf"
});

// Custom filename in download dialog
app.get('/download/report', (req, res) => {
  res.download(
    path.join(__dirname, 'files', 'report-2024-q1.pdf'),
    'Q1-Report.pdf'  // user sees this name
  );
});

// With error handling
app.get('/download/:file', (req, res) => {
  const filePath = path.join(__dirname, 'files', req.params.file);
  res.download(filePath, (err) => {
    if (err) {
      res.status(404).json({ error: 'File not found' });
    }
  });
});

res.download() vs res.sendFile()

MethodBrowser behavior
res.sendFile()Displays the file inline (if browser can render it)
res.download()Prompts download dialog (Content-Disposition: attachment)

8. res.set() / res.header() — setting custom headers

res.set() and res.header() are aliases — they do the same thing:

// Single header
app.get('/api/data', (req, res) => {
  res.set('X-Request-Id', 'abc-123');
  res.set('Cache-Control', 'no-store');
  res.json({ data: 'hello' });
});

// Multiple headers at once
app.get('/api/data', (req, res) => {
  res.set({
    'X-Request-Id': 'abc-123',
    'X-RateLimit-Remaining': '99',
    'Cache-Control': 'public, max-age=3600',
  });
  res.json({ data: 'hello' });
});

// CORS headers (usually use the `cors` package instead)
app.use((req, res, next) => {
  res.set('Access-Control-Allow-Origin', '*');
  res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  next();
});

Common custom headers

HeaderPurpose
X-Request-IdUnique ID for request tracing
X-RateLimit-RemainingHow many API calls remain
Cache-ControlBrowser/CDN caching behavior
X-Powered-ByFramework identifier (Express sets this by default — disable in production)
// Disable X-Powered-By for security (don't reveal your stack)
app.disable('x-powered-by');

9. res.cookie() — setting cookies

// Basic cookie
app.get('/set-cookie', (req, res) => {
  res.cookie('username', 'alice');
  res.send('Cookie set');
});

// Cookie with options
app.get('/set-cookie', (req, res) => {
  res.cookie('sessionId', 'abc123', {
    httpOnly: true,       // Not accessible via JavaScript (XSS protection)
    secure: true,         // Only sent over HTTPS
    maxAge: 3600000,      // 1 hour in milliseconds
    sameSite: 'strict',   // CSRF protection
    path: '/',            // Available on all paths
  });
  res.send('Secure cookie set');
});

// Clear a cookie
app.get('/logout', (req, res) => {
  res.clearCookie('sessionId');
  res.redirect('/');
});
OptionWhat it does
httpOnlyCannot be read by document.cookie in browser JS
secureOnly sent over HTTPS connections
maxAgeLifetime in milliseconds
expiresExplicit expiration date
sameSite'strict', 'lax', or 'none' — CSRF protection
domainWhich domain receives the cookie
pathWhich URL paths receive the cookie

Note: To read cookies from incoming requests, you need the cookie-parser middleware:

npm install cookie-parser
const cookieParser = require('cookie-parser');
app.use(cookieParser());

app.get('/profile', (req, res) => {
  const sessionId = req.cookies.sessionId;
  // ...
});

10. Response best practices: consistent JSON structure

Every API should follow a consistent response format across all endpoints.

Pattern A: Wrapper object

// Success
{
  "success": true,
  "data": {
    "id": 1,
    "name": "Alice"
  }
}

// Error
{
  "success": false,
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "No user found with ID 99"
  }
}

Pattern B: Flat with meta (common in larger APIs)

// Success (single resource)
{
  "data": {
    "id": 1,
    "name": "Alice"
  }
}

// Success (list with pagination)
{
  "data": [
    { "id": 1, "name": "Alice" },
    { "id": 2, "name": "Bob" }
  ],
  "meta": {
    "total": 100,
    "page": 1,
    "limit": 10,
    "totalPages": 10
  }
}

// Error
{
  "error": {
    "status": 404,
    "code": "NOT_FOUND",
    "message": "User not found"
  }
}

Helper functions for consistency

// utils/response.js
function sendSuccess(res, data, statusCode = 200) {
  return res.status(statusCode).json({
    success: true,
    data,
  });
}

function sendError(res, message, statusCode = 500, code = 'SERVER_ERROR') {
  return res.status(statusCode).json({
    success: false,
    error: { code, message },
  });
}

module.exports = { sendSuccess, sendError };
// Usage in route handlers
const { sendSuccess, sendError } = require('./utils/response');

app.get('/api/users/:id', (req, res) => {
  const user = findUser(req.params.id);
  if (!user) {
    return sendError(res, 'User not found', 404, 'USER_NOT_FOUND');
  }
  sendSuccess(res, user);
});

app.post('/api/users', (req, res) => {
  if (!req.body.name) {
    return sendError(res, 'Name is required', 400, 'VALIDATION_ERROR');
  }
  const user = createUser(req.body);
  sendSuccess(res, user, 201);
});

11. Real-world response patterns

Pagination response

app.get('/api/products', (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 10;
  const offset = (page - 1) * limit;

  // Simulate database query
  const allProducts = getProducts();
  const products = allProducts.slice(offset, offset + limit);

  res.json({
    data: products,
    meta: {
      total: allProducts.length,
      page,
      limit,
      totalPages: Math.ceil(allProducts.length / limit),
      hasNextPage: page * limit < allProducts.length,
      hasPrevPage: page > 1,
    },
  });
});

Conditional response based on Accept header

app.get('/api/users/:id', (req, res) => {
  const user = { id: 1, name: 'Alice' };

  // Respond based on what the client accepts
  res.format({
    'application/json': () => {
      res.json(user);
    },
    'text/html': () => {
      res.send(`<h1>${user.name}</h1>`);
    },
    'text/plain': () => {
      res.send(`User: ${user.name}`);
    },
    default: () => {
      res.status(406).json({ error: 'Not Acceptable' });
    },
  });
});

12. Complete response method reference

MethodPurposeSets Content-Type
res.send(body)Send response (auto-detect type)Auto
res.json(obj)Send JSON responseapplication/json
res.status(code)Set status (chainable)--
res.sendStatus(code)Set status + send status text as bodytext/plain
res.redirect([code,] url)Redirect to URL--
res.render(view, data)Render templatetext/html
res.sendFile(path)Send file (inline)Based on extension
res.download(path)Send file (attachment)Based on extension
res.set(header, value)Set response header--
res.cookie(name, val)Set cookie--
res.clearCookie(name)Remove cookie--
res.type(type)Set Content-Type shorthandGiven type
res.format(obj)Content negotiationVaries
res.end()End response (no body)--
res.append(header, val)Append to existing header--

13. Key takeaways

  1. res.send() auto-detects content type; res.json() always sends JSON — prefer res.json() for APIs.
  2. res.status() is chainableres.status(404).json({...}) is the standard pattern.
  3. Use res.redirect() for URL redirects (301 permanent, 302 temporary).
  4. res.download() triggers a file download; res.sendFile() displays inline.
  5. Set custom headers with res.set() and cookies with res.cookie().
  6. Standardize your response format across all endpoints — pick a pattern and stick to it.
  7. Always return after sending error responses to prevent double-send errors.

Explain-It Challenge

Explain without notes:

  1. What is the difference between res.send({ x: 1 }) and res.json({ x: 1 })? When does it matter?
  2. Why should you always return after calling res.status(400).json(...) in a route handler?
  3. Describe two common JSON response envelope patterns for APIs.
  4. What does res.cookie('token', 'abc', { httpOnly: true }) protect against?

Navigation: <- 3.4.b — Setting Up Express Server | 3.4.d — Query Parameters and URL Parameters ->