Response

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the official API reference on 2026-10-29. The response object is the second argument of every route handler and middleware function. Express builds it by adding a set of helper methods to Node.js’s http.ServerResponse, so everything Node provides (res.write(), res.writeHead(), res.setHeader(), res.getHeader(), res.removeHeader(), res.flushHeaders(), res.statusCode, res.writableEnded and the finish and close events) keeps working next to the Express helpers. The examples all belong to the Bookshelf API that grows across the section, use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets.

The Express team keeps the 5.1 to 5.3 releases deliberately small. Looking at the release notes, the res method signatures documented here did not change between 5.0 and 5.3; what moved are the dependencies that implement them (send for files, cookie for cookies, qs, proxy-addr and body-parser elsewhere). The Express 5 changes that do matter, listed in Removed and changed in Express 5, all arrived with 5.0.

The helpers at a glance

Nearly every helper returns res, so calls chain (res.status(201).location(url).json(book)). Only the helpers that finish the response (send, json, jsonp, end, redirect, render without a callback, sendFile, download, sendStatus, format) are terminal: nothing useful can be done with the response after calling them, and calling a second one is the classic "headers already sent" bug (res.headersSent and the double-response bug).

Need Helper Section

Set the status line

res.status(code), res.sendStatus(code)

Status: res.status() and res.sendStatus()

Read or write headers

res.get(), res.set() (alias res.header()), res.append(), res.type(), res.vary(), res.links(), res.location(), res.attachment()

Headers

Send a body

res.send(), res.json(), res.jsonp(), res.end()

Sending a body: res.send(), JSON: res.json() and the json * settings, res.jsonp()

Send a file

res.sendFile(), res.download()

Files: res.sendFile() and res.download()

Redirect

res.redirect(), res.location()

Redirects: res.redirect()

Render HTML from a template

res.render()

Templates: res.render()

Pick a representation from Accept

res.format()

Content negotiation: res.format()

Cookies

res.cookie(), res.clearCookie()

Cookies: res.cookie() and res.clearCookie()

Share data with later layers and views

res.locals, res.app, res.req, res.headersSent

Properties

Properties

Property Type Meaning

res.app

Application

The Express application that is using the middleware. It is the same object as req.app, so a handler can read settings (res.app.get('env')) and app.locals without importing the application module.

res.req

Request

The request that belongs to this response; res.req === req is always true. Useful in helper functions that were handed only res.

res.headersSent

boolean

true once the headers have been written to the socket. After that point no helper can change the status or a header. See res.headersSent and the double-response bug.

res.locals

object

Variables scoped to this request and visible to the views rendered during it. They are not shared between requests; use app.locals for values that live for the whole application. Never use a key that comes from user input.

res.locals is the standard place for middleware to hand data to a later handler or template. Its role in a pipeline is explained in Middleware Fundamentals, and Template Engines and Views shows how res.render() merges it with the locals of the call.

Status: res.status() and res.sendStatus()

res.status(code) sets the numeric status code and returns res so that it can be chained. It is the chainable form of Node’s res.statusCode. Express 5 validates the argument: the code must be an integer from 100 to 999, and anything else (a string such as '404', a float, undefined, NaN, 1000) throws an error instead of silently producing a broken response. In Express 4 an invalid code failed later and less clearly, inside Node.

res.sendStatus(code) sets the status and sends the standard reason phrase as a plain-text body (res.sendStatus(404) sends Not Found). For a code Node does not know, the body is the number itself. It is a shortcut for tiny responses such as health checks (res.sendStatus(204)) and is not suited to an API that returns structured errors; for those, use res.status(code).json(problem).

Pick the status with care: 200 for a successful read, 201 plus a Location header for a created resource, 204 for a success with no body (Express strips the body and the Content-Type, Content-Length and Transfer-Encoding headers for 204 and 304), 400 and 422 for client mistakes, 401 and 403 for authentication and authorization failures, 404 for a missing resource, 409 for conflicts, and 5xx for faults on the server. The full status-code conventions of a REST API are covered in Building REST APIs.

Headers

res.get(), res.set() and res.header()

res.get(field) returns the value of a response header that was already set; the name is case-insensitive. res.set(field, value) sets a header, replacing any previous value, and res.set(object) sets several headers at once; res.header() is an alias. A value can be a string or an array of strings (an array produces several lines of the same header). When the field is Content-Type, Express adds charset=utf-8 to a text type that has no charset, and it rejects an array for that header.

res.set('Cache-Control', 'no-store');
res.set({ 'X-Request-Id': req.id, 'Content-Language': 'en' });
res.get('content-language'); // 'en'

Because set() replaces, calling it for a header that several layers contribute to (Vary, Link, Set-Cookie) loses what an earlier layer added. Use the helpers designed for accumulation: res.append(), res.vary(), res.links() and res.cookie().

res.append()

res.append(field, value) adds a value to a header, creating the header when it does not exist, and accepts a string or an array. A later res.set() for the same field overwrites everything that was appended, so order matters:

res.append('Link', ['<https://api.bookshelf.example/books?page=2>; rel="next"']);
res.append('Set-Cookie', 'theme=dark; Path=/; HttpOnly; SameSite=Lax');

For cookies prefer res.cookie(), which serializes and escapes the value for you.

res.type()

res.type(type) (alias res.contentType()) sets Content-Type. An argument that contains a slash is used as the MIME type as it is; anything else is looked up as a file extension through the mime-types package, with or without the leading dot:

res.type('.html');            // text/html; charset=utf-8
res.type('json');             // application/json; charset=utf-8
res.type('application/pdf');  // application/pdf

res.send() only chooses a default Content-Type when none is set, so calling res.type() first is the way to send a string that is not HTML.

res.vary()

res.vary(field) adds a field to the Vary header unless it is already listed, which tells caches that the representation depends on that request header. Any response whose body depends on Accept, Accept-Language, Accept-Encoding or Origin must say so, otherwise a shared cache can serve one client’s variant to another. Express 5 throws a TypeError when res.vary() is called without a field (Express 4 ignored the call). res.format() and the cors middleware call it for you.

res.links(object) builds the Link header from an object whose keys are relation names and whose values are a URL or an array of URLs. Calls append to an existing Link header. It is the right tool for pagination and discovery:

res.links({
  prev: 'https://api.bookshelf.example/books?page=1',
  next: 'https://api.bookshelf.example/books?page=3',
  last: ['https://api.bookshelf.example/books?page=9'],
});
// Link: <https://api.bookshelf.example/books?page=1>; rel="prev", ...

The helper does not percent-encode or validate the URLs it receives, so build them from trusted pieces.

res.location()

res.location(url) sets the Location header and returns res. It encodes characters that are not valid in a URL but it does not check where the URL points; see Open-redirect safety. Express 5 removed the special value 'back'; read the Referer header (or better, an explicit parameter) yourself.

res.attachment()

res.attachment([filename]) sets Content-Disposition: attachment. With a file name it also sets Content-Type from the extension and adds the filename parameter (and a filename* parameter for non-ASCII names). It sends nothing by itself; combine it with res.send() or a stream. res.download() does both steps for a file on disk.

res.attachment('books.csv');
res.send('id,title\n1,Dune\n');

Sending a body: res.send()

res.send([body]) is the general-purpose terminal helper. It sets Content-Length, handles HEAD, builds an ETag when the etag application setting allows it, answers 304 Not Modified when the request is fresh, and chooses a Content-Type from the type of body when one is not already set:

Argument Default Content-Type Notes

string

text/html; charset=utf-8

Call res.type('text') or res.set('Content-Type', type) first for other types. If a Content-Type is already set, only the charset is added.

Buffer

application/octet-stream

Used as it is; the type of a Buffer is never guessed.

object, array, boolean, number

application/json

Delegated to res.json() so the json * settings apply. In Express 5 a number is a JSON body; it is not a status code.

null, undefined or nothing

none

Ends the response with an empty body. Use res.json(null) to send the JSON value null.

Some details worth remembering:

  • A 204 or 304 response has its body and entity headers removed even if you pass a body.

  • A stream is not accepted by res.send(). Pipe it (stream.pipeline(readable, res)) or use res.sendFile().

  • Strings are sent as UTF-8 by default. An ETag is generated for non-empty bodies only.

  • res.send() can be called once. A second call raises ERR_HTTP_HEADERS_SENT (res.headersSent and the double-response bug).

res.end()

res.end([data], [encoding], [callback]) is Node’s own method. It ends the response without any of the work above (no Content-Type, no ETag, no HEAD handling). Use it for a response with no body, such as res.status(204).end(), or when writing to the socket yourself with res.write().

JSON: res.json() and the json * settings

res.json([body]) serializes body with JSON.stringify(), sets Content-Type: application/json; charset=utf-8 unless one is already set, and sends the result through res.send(). It accepts any JSON value, including null, arrays, strings, numbers and booleans. Three application settings change how the serialization happens; they are described in Application and Settings and apply to res.json() and res.jsonp():

Setting Effect Typical use

json replacer

The second argument of JSON.stringify(): a function or an allow-list array applied to every response.

Dropping passwordHash or __v keys everywhere, or converting BigInt values.

json spaces

The third argument: indentation. Off by default.

Readable output in development. Do not enable it in production, where it only adds bytes.

json escape

When true, escapes <, > and & as Unicode escapes in the output.

Defence in depth when a JSON response could be sniffed or embedded as HTML.

JSON.stringify() calls toJSON() on values that define it (so a Date becomes an ISO string and a Mongoose document can control its own shape), and it throws on circular structures and on BigInt. A thrown error inside an async handler is forwarded to the error handler in Express 5; in a callback it is not.

Never return a model or an ORM entity directly. Map it to a response shape that lists exactly the fields clients may see; that single habit prevents leaking password hashes, internal ids and flags, a mistake several of the books make.

res.jsonp()

res.jsonp([body]) behaves like res.json() but wraps the result in a function call when the request carries a callback name in its query string. The parameter name is callback by default and can be changed with the jsonp callback name setting. Express restricts the name to characters that cannot break out of the call, removes anything else, prefixes the body with a comment, and sets X-Content-Type-Options: nosniff, but JSONP is still a legacy mechanism: it executes the response as script in the calling page, has no error reporting and cannot be protected by CORS. Use it only to support an existing client that cannot be changed; new code should use res.json() with the cors middleware (see CORS).

app.set('jsonp callback name', 'cb');
// GET /legacy/books?cb=render  ->  /**/ typeof render === 'function' && render([...]);
app.get('/legacy/books', (req, res) => res.jsonp([{ id: '1', title: 'Dune' }]));

Redirects: res.redirect()

res.redirect([status], url) sets the Location header (through res.location()), sets the status (302 Found unless you pass one), and sends a short body that is negotiated against Accept (a small HTML page with a link, or the text Found. Redirecting to <url>, or an empty body for HEAD). The URL may be absolute (https://example.com), root-relative (/login), or relative to the current URL (post/new, ..); relative targets resolve the way a browser resolves them, so the trailing slash on the current path decides the result: from /blog/admin/, post/new leads to /blog/admin/post/new, and from /blog/admin to /blog/post/new.

Choose the status deliberately:

Status Use

301, 308

Permanent move. 308 keeps the method and body, 301 lets clients turn a POST into a GET. Caches remember them for a long time, so change them only when the move is final.

302, 307

Temporary redirect. 307 keeps the method and body. 302 is the default and is fine after a form POST.

303 See Other

Redirect to a GET after a POST, PUT or DELETE: the classic post/redirect/get pattern.

Express 5 removed the res.redirect(url, status) argument order and the magic 'back' URL (see Removed and changed in Express 5).

Open-redirect safety

Both res.redirect() and res.location() encode the URL but never validate it, so passing a value taken from req.query or req.body produces an open redirect: an attacker sends a victim a link on your domain that bounces to a phishing page. Accept only destinations you can check: a fixed map of names, or a relative path that starts with a single /, is not protocol-relative (//evil.example) and does not contain a backslash or control characters. When an absolute URL is needed, parse it with new URL() and compare the origin against an allow-list.

// redirect-safety.mjs
import express from 'express';

const app = express();
const allowedOrigins = new Set(['https://bookshelf.example', 'https://admin.bookshelf.example']);

function safeRedirectTarget(value, fallback = '/') {
  if (typeof value !== 'string' || value.length === 0 || value.length > 2048) {
    return fallback;
  }
  // Same-site relative path: exactly one leading slash, no backslash, no control characters.
  if (value.startsWith('/') && !value.startsWith('//') && !/[\\\u0000-\u001f]/.test(value)) {
    return value;
  }
  try {
    const url = new URL(value);
    return allowedOrigins.has(url.origin) ? url.href : fallback;
  } catch {
    return fallback;
  }
}

app.get('/login/done', (req, res) => {
  res.redirect(303, safeRedirectTarget(req.query.next, '/books'));
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
  console.log('Listening on http://localhost:3000');
});

Templates: res.render()

res.render(view, [locals], [callback]) renders a view with the configured template engine and sends the result as HTML. view is a file name relative to the views setting (or an absolute path); if it has no extension, the view engine setting supplies one, and if it has one, Express loads the matching engine module and calls its __express function. The variables available to the template are the merge of app.locals, res.locals and the locals argument, in that order of increasing priority.

  • Without a callback, Express sends the HTML with status 200 (set a different status first with res.status()), and a render error goes to next(err).

  • With a callback, callback(err, html) receives the result and nothing is sent automatically, so the handler must send the HTML or forward the error. This is how to render e-mail bodies or fragments.

  • In Express 5 rendering is always asynchronous, even for an engine that is synchronous or a cached view, so code that relied on the callback running immediately must be adapted.

  • View caching is on in production (view cache) and off elsewhere.

  • Never build view from user input, and never copy req.body or req.query wholesale into locals: both open the door to file inclusion and to overwriting options such as settings. Escape output in templates.

res.status(404).render('errors/not-found', { title: 'Not found', path: req.path });

Template engines, layouts and escaping are covered in Template Engines and Views.

Files: res.sendFile() and res.download()

res.sendFile()

res.sendFile(path, [options], [callback]) streams a file and sets Content-Type from its extension, Content-Length, Last-Modified, an ETag, Cache-Control and Accept-Ranges. It supports conditional requests (304) and byte ranges (206), so media can be seeked. The work is done by the send package, the same one behind express.static().

The path must be absolute unless the root option is given; otherwise Express throws. Relative paths may contain .. only when the result stays inside root.

Option Default Meaning

root

none

Directory against which a relative path is resolved. Requests that escape it are rejected with 403. Always set it when any part of the path comes from the request.

maxAge

0

max-age of the Cache-Control header, in milliseconds or as an ms string such as '1d'.

immutable

false

Adds immutable to Cache-Control; set maxAge as well. Use it for fingerprinted file names.

cacheControl

true

Set to false to stop Express writing Cache-Control and set your own.

lastModified

true

Set Last-Modified from the file’s modification time.

headers

none

Object of extra headers sent with the file.

dotfiles

'ignore'

'allow' serves dotfiles, 'deny' answers 403, 'ignore' answers 404 as if the file did not exist.

acceptRanges

true

Allow Range requests.

The callback is called with an error when the transfer fails (a missing file has err.status === 404, an aborted download has code ECONNABORTED) and with no argument on success. When you supply a callback, Express no longer finishes the failed response for you: you must send an error response, call next(err), or end the response. Check res.headersSent first, because the transfer may already be under way.

// covers.mjs
import { join } from 'node:path';
import express from 'express';

const app = express();
const coversDirectory = join(import.meta.dirname, 'covers');

app.get('/books/:id/cover', (req, res, next) => {
  // The :id pattern is restricted, and root keeps the lookup inside covers/.
  if (!/^[\w-]{1,40}$/.test(req.params.id)) {
    return res.status(400).json({ error: 'invalid id' });
  }
  res.sendFile(`${req.params.id}.png`, {
    root: coversDirectory,
    dotfiles: 'deny',
    maxAge: '1h',
    headers: { 'X-Content-Type-Options': 'nosniff' },
  }, (error) => {
    if (error && !res.headersSent) {
      next(error.status === 404 ? undefined : error);
    }
  });
});

app.listen(3000);

Source: https://expressjs.com/en/api/response/ (res.sendFile).

res.download()

res.download(path, [filename], [options], [callback]) transfers the file as an attachment: it calls res.attachment() to set Content-Disposition, then res.sendFile(). The optional filename overrides the name shown in the browser’s save dialog, options are those of sendFile, and the callback has the same contract (end the response or call next, and check res.headersSent). The same path-safety rules apply: use root when the path derives from user input.

res.download(join(exportsDirectory, 'books-2026.csv'), 'bookshelf-books.csv', (error) => {
  if (error && !res.headersSent) {
    next(error);
  }
});

Large downloads, uploads and streaming are covered in File Uploads and Downloads.

Content negotiation: res.format()

res.format(object) picks one handler according to the request’s Accept header using req.accepts(), honouring quality values. The keys are MIME types or extension shortcuts (text, html, json); an optional default key handles everything else. It sets Vary: Accept and the Content-Type of the chosen type automatically (the handler may override it). If there is no Accept header the first handler runs; if nothing matches and there is no default, Express passes a 406 Not Acceptable error to next().

// negotiation.mjs
import express from 'express';

const app = express();
const book = { id: '1', title: 'Dune', author: 'Frank Herbert' };

app.get('/books/1', (req, res) => {
  res.format({
    text() {
      res.send(`${book.title} by ${book.author}`);
    },
    html() {
      res.send(`<h1>${book.title.replace(/[&<>]/g, '')}</h1>`);
    },
    json() {
      res.json(book);
    },
    default() {
      res.status(406).type('text').send('Not Acceptable');
    },
  });
});

app.listen(3000);

Source: https://expressjs.com/en/api/response/ (res.format). Quality values, Vary and caching are explained further in Content Negotiation and HTTP Caching.

Cookies: res.cookie() and res.clearCookie()

res.cookie(name, value, [options]) appends a Set-Cookie header and returns res, so several cookies can be chained. It only sets a header: nothing is stored on the server. A value that is an object is serialized as JSON, and the value is escaped with encodeURIComponent unless you supply another synchronous encode function. Options that you leave out take the defaults of the RFC 6265 attribute rules, which is why a cookie without options is a session cookie visible to scripts and sent over plain HTTP.

Option Type Meaning

domain

string

Domain the cookie is sent to. Omit it for a host-only cookie, which is safer than a domain cookie shared by subdomains.

path

string, default '/'

Path prefix of requests that carry the cookie.

expires

Date

Absolute expiry in GMT. Without expires or maxAge the cookie is a session cookie.

maxAge

number

Lifetime in milliseconds from now (the cookie header carries seconds). Takes precedence in browsers when both are present.

httpOnly

boolean

Hides the cookie from document.cookie. Set it for every session or token cookie.

secure

boolean

Send only over HTTPS. Required for SameSite=None, for partitioned and for the __Host- and __Secure- prefixes. Behind a proxy that terminates TLS, configure trust proxy or frameworks that check req.secure refuse to set it.

signed

boolean

Appends a signature so that tampering is detected. Needs cookie-parser configured with a secret; read the value from req.signedCookies. A signed cookie is not encrypted, it is only tamper-evident.

sameSite

boolean or string

'lax' is the best default; 'strict' (or true) for cookies that must never accompany cross-site navigation; 'none' for cross-site use, which also needs secure; false leaves the attribute out.

partitioned

boolean

Adds the Partitioned attribute (CHIPS: cookies having independent partitioned state). The browser keeps one copy of the cookie per top-level site, which keeps an embedded widget working without allowing cross-site tracking. Needs secure and normally sameSite: 'none'.

priority

string

Value of the Priority attribute: 'low', 'medium' or 'high'. A hint to browsers about which cookies to evict first when the limit is reached.

encode

function

Synchronous encoder, default encodeURIComponent. Pass String to leave the value untouched. Asynchronous functions are not supported.

// cookies.mjs
import express from 'express';
import cookieParser from 'cookie-parser';

const app = express();
const cookieSecret = process.env.COOKIE_SECRET;
if (!cookieSecret) throw new Error('COOKIE_SECRET is not set');
app.use(cookieParser(cookieSecret));

app.post('/preferences', express.json(), (req, res) => {
  const theme = req.body?.theme === 'dark' ? 'dark' : 'light';
  res
    .status(204)
    .cookie('theme', theme, {
      httpOnly: true,
      secure: app.get('env') === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days, in milliseconds
      signed: true,
      priority: 'low',
    })
    .end();
});

// A cookie embedded in a third-party page, kept per top-level site.
app.get('/widget', (req, res) => {
  res.cookie('widget_state', 'compact', {
    httpOnly: true,
    secure: true,
    sameSite: 'none',
    partitioned: true,
    path: '/widget',
  }).send('ok');
});

app.post('/preferences/reset', (req, res) => {
  // The options must match the ones used to set the cookie, apart from expires and maxAge.
  res.clearCookie('theme', { httpOnly: true, secure: app.get('env') === 'production', sameSite: 'lax' });
  res.sendStatus(204);
});

app.listen(3000);

Source: https://expressjs.com/en/api/response/ (res.cookie, res.clearCookie).

res.clearCookie(name, [options]) sends an expired cookie. A browser removes a cookie only when the name, domain and path (and, for partitioned cookies, the partition) match the original, so pass the same options you set. Express 5 ignores expires and maxAge here: they are forced to an expiry in the past, where Express 4 let a caller override them and accidentally keep the cookie alive. Cookie parsing, sessions, cookie-session and the choice between them live in Cookies and Sessions.

res.headersSent and the double-response bug

Once the status line and headers have been flushed, they cannot change. res.headersSent is true from that moment, and trying to set a header or send again raises ERR_HTTP_HEADERS_SENT. The usual causes:

  • a handler that sends a response in an if branch and forgets to return, so the code continues and sends again;

  • both a callback and a promise path answering;

  • an error that happens after a partial response (a failed sendFile, a thrown error while streaming);

  • an error-handling middleware that tries to send a JSON error after the response has begun.

An error-handling middleware must check the flag and delegate to Express when the response has started, because the default handler then closes the connection, which is the only correct thing left to do:

app.use((error, req, res, next) => {
  if (res.headersSent) {
    return next(error);
  }
  res.status(error.status ?? 500).json({ error: error.expose ? error.message : 'Internal Server Error' });
});

Removed and changed in Express 5

Most "it worked in Express 4" failures on this object come from signatures that were deprecated for years and removed in Express 5.0. Every removed form has a one-line replacement, and the official codemods (for example npx codemod@latest @expressjs/v5-migration-recipe) rewrite most of them. Migration as a whole is covered in Migrating and What’s Changed.

Express 4 code Express 5 Replacement

res.json(obj, status)

Removed

res.status(status).json(obj)

res.jsonp(obj, status)

Removed

res.status(status).jsonp(obj)

res.send(body, status)

Removed

res.status(status).send(body)

res.send(status) with a number

Removed; a number is sent as a JSON body

res.sendStatus(status), or res.status(status).end()

res.redirect(url, status)

Removed

res.redirect(status, url)

res.redirect('back'), res.location('back')

Removed

Redirect to an explicit, validated URL; if you must use the referrer, read req.get('Referrer') and check it with an allow-list

res.sendfile(path)

Removed

res.sendFile(path, options) (camel case; the path must be absolute or use root)

hidden and from options of res.sendFile()

Removed

dotfiles: 'allow' replaces hidden; use root instead of from

res.clearCookie(name, { maxAge })

maxAge and expires ignored

Drop them; match path, domain, secure and sameSite

res.status('404') or an out-of-range code

Throws unless an integer from 100 to 999

Pass a number

res.vary() without a field

Throws

Always pass a header name

res.render() callback running synchronously

Always asynchronous

Do not rely on timing

Two related behaviours changed outside res: req.body is undefined when no body parser ran, and rejected promises returned from handlers are forwarded to the error handler, which removes the need for a try/catch around every await that only calls next(error).

A complete example: a Bookshelf response toolkit

The following program uses most of the helpers together: a created resource with Location, pagination with Link, negotiated output, an attachment, safe redirect, and a final error handler that respects headersSent. Run it with npm install express and node response-demo.mjs.

// response-demo.mjs
import { randomUUID } from 'node:crypto';
import express from 'express';

const app = express();
app.set('etag', 'strong');
app.set('json spaces', app.get('env') === 'production' ? 0 : 2);
app.disable('x-powered-by');

const books = new Map([
  ['1', { id: '1', title: 'Dune', author: 'Frank Herbert' }],
  ['2', { id: '2', title: 'Hyperion', author: 'Dan Simmons' }],
]);

// Response shape: list exactly the fields clients may see.
const toDto = ({ id, title, author }) => ({ id, title, author });

app.use(express.json({ limit: '50kb' }));

app.get('/books', (req, res) => {
  const page = Math.max(1, Number.parseInt(req.query.page ?? '1', 10) || 1);
  const size = 1;
  const items = [...books.values()].slice((page - 1) * size, page * size).map(toDto);
  const last = Math.max(1, Math.ceil(books.size / size));
  const base = `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}`;
  const links = { first: `${base}?page=1`, last: `${base}?page=${last}` };
  if (page > 1) links.prev = `${base}?page=${page - 1}`;
  if (page < last) links.next = `${base}?page=${page + 1}`;
  res.links(links).set('X-Total-Count', String(books.size)).json(items);
});

app.post('/books', (req, res) => {
  const { title, author } = req.body ?? {};
  if (typeof title !== 'string' || typeof author !== 'string') {
    return res.status(422).json({ error: 'title and author are required' });
  }
  const book = { id: randomUUID(), title, author };
  books.set(book.id, book);
  res.status(201).location(`/books/${book.id}`).json(toDto(book));
});

app.get('/books/export.csv', (req, res) => {
  const rows = ['id,title,author', ...[...books.values()].map((b) => `${b.id},"${b.title}","${b.author}"`)];
  res.attachment('books.csv').type('csv').send(`${rows.join('\n')}\n`);
});

app.get('/books/:id', (req, res) => {
  const book = books.get(req.params.id);
  if (!book) {
    return res.status(404).json({ error: 'book not found' });
  }
  res.format({
    json: () => res.json(toDto(book)),
    text: () => res.type('text').send(`${book.title} by ${book.author}\n`),
    default: () => res.status(406).type('text').send('Not Acceptable\n'),
  });
});

app.delete('/books/:id', (req, res) => {
  books.delete(req.params.id);
  res.status(204).end();
});

app.get('/old-books/:id', (req, res) => {
  res.redirect(308, `/books/${encodeURIComponent(req.params.id)}`);
});

app.use((req, res) => {
  res.status(404).json({ error: 'not found' });
});

app.use((error, req, res, next) => {
  if (res.headersSent) {
    return next(error);
  }
  res.status(error.status ?? 500).json({ error: error.expose ? error.message : 'Internal Server Error' });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
  console.log('Bookshelf listening on http://localhost:3000');
});

Common pitfalls

  • Sending twice (res.headersSent and the double-response bug): always return when you respond early.

  • Calling res.status() after res.send(); the status is applied before the body, so the order must be res.status(404).json(body).

  • Setting a header after the body started (for example res.set() inside a finish listener): too late.

  • Returning a database document directly from res.json(): leaks fields; map to a DTO.

  • Passing user input to res.redirect(), res.location(), res.render() (as the view name), res.sendFile() (without root) or res.download().

  • Using res.send() with a string that is actually JSON: it is labelled text/html. Use res.json() or res.type('json').

  • Forgetting Vary when the body depends on a request header.

  • Using res.cookie() with sameSite: 'none' without secure, which browsers reject.

  • Forgetting that res.clearCookie() needs the same path and domain as the cookie it removes.

  • Calling res.sendFile() with a relative path and no root: Express throws.

Where the books are outdated

The four books consulted for this section target Express 4.0 to 4.18 and Node.js 0.10 to 20. Their treatment of the response object is accurate as a picture of the concepts, but these practices have since changed:

Older practice Where it appears Current behaviour

Status-first call signatures such as res.send(200, body) and res.json(500, error).

Sharma, chapters 2 and 4; Monteiro 2014, chapter 3

Removed in Express 5. Chain res.status(code).send(body) or .json(body).

res.sendStatus() and res.send(404) used interchangeably.

Monteiro 2014, chapter 3

res.send(number) is a JSON body in Express 5; use res.sendStatus(404) for a bare status.

res.redirect('back').

Monteiro 2016, chapter 4

Removed. Redirect to an explicit and validated URL.

res.sendfile() in lower case and the hidden option.

Monteiro 2014, chapter 4; Monteiro 2016, chapter 3

Replaced by res.sendFile() with dotfiles and root; user-controlled paths need root.

Returning the whole Mongoose document, including hashes, with res.json(user).

Sharma, chapter 6

Return a response shape with an explicit field list; configure toJSON or use a DTO function.

Error responses written as res.send(err) or res.json(err) in every callback.

Monteiro 2014 and 2016; Sharma

One error-handling middleware that sets the status and a safe message, and checks res.headersSent.

Session or JWT cookies set without httpOnly, secure or sameSite.

Sharma, chapter 6; Monteiro 2016, chapter 6

Always set httpOnly, secure in production and sameSite; see the options table above.

res.render() assumed to run synchronously, and Jade as the default engine.

Monteiro 2014, chapter 3; Monteiro 2016, chapter 1

Rendering is asynchronous in Express 5; Jade is now Pug.