The Response Object
Every Express handler receives a response object, conventionally named res. It is your interface for talking back to the client: setting the status code, writing headers, and sending a body. Express layers convenient, content-aware methods on top of Node’s raw http.ServerResponse, so you rarely touch the low-level stream directly. Understanding res — and the single most important rule that you may only end the response once — is the key to writing correct handlers.
Sending a body with res.send
res.send() is the general-purpose method for replying with a body. Its power is that it inspects the argument’s type and sets a sensible Content-Type automatically, then ends the response for you.
const express = require("express");
const app = express();
app.get("/text", (req, res) => {
res.send("Hello, world"); // Content-Type: text/html
});
app.get("/data", (req, res) => {
res.send({ id: 1, name: "Ada" }); // Content-Type: application/json
});
app.get("/raw", (req, res) => {
res.send(Buffer.from("binary")); // Content-Type: application/octet-stream
});
app.listen(3000);
The type inference follows a simple table:
| Argument type | Inferred Content-Type |
|---|---|
String | text/html |
Object / Array | application/json |
Buffer | application/octet-stream |
Boolean / Number | application/json |
Tip: Because a string defaults to
text/html, returning user-supplied text withres.sendcan open an XSS vector if the browser renders it. For pure text, set the type explicitly withres.type("text/plain").send(value).
Sending JSON with res.json
When the body is data, prefer res.json(). It always serializes the argument with JSON.stringify and forces Content-Type: application/json, regardless of the value’s type — so res.json(null) or res.json("a string") produce valid JSON, whereas res.send would treat the string as HTML.
app.get("/users/:id", async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user);
});
Output:
{ "id": "42", "name": "Ada", "role": "admin" }
res.json also respects app settings such as json spaces (pretty-printing) and a custom json replacer, making it the canonical choice for APIs.
Setting the status code with res.status
res.status(code) sets the HTTP status but does not send anything — it returns res so you can chain a body method onto it. This pairing of status plus body is the idiomatic Express response.
app.post("/articles", express.json(), async (req, res) => {
if (!req.body.title) {
return res.status(400).json({ error: "title is required" });
}
const article = await db.createArticle(req.body);
res.status(201).json(article); // 201 Created
});
Calling res.status(code) on its own leaves the response open and the request hanging. Always follow it with .send(), .json(), or .end(). To send a status with its standard reason phrase as the body, use res.sendStatus(code) — res.sendStatus(404) writes "Not Found".
Setting headers with res.set
res.set() (aliased res.header()) writes response headers. Pass a name and value for one header, or an object for several at once. Like res.status, it returns res for chaining and does not end the response.
app.get("/download", (req, res) => {
res
.set({
"Cache-Control": "no-store",
"X-Powered-By": "DevCraftly",
})
.type("text/csv")
.send("id,name\n1,Ada\n");
});
A few related helpers exist for common headers: res.type(mime) sets Content-Type, res.location(url) sets Location, and res.cookie(name, value, opts) appends a Set-Cookie. Read a header you have already staged with res.get(field).
Ending the cycle with res.end and the send-once rule
res.end() terminates the response without a body (or after streaming raw data). It is inherited straight from Node and is useful for empty replies such as a 204 No Content.
app.delete("/articles/:id", async (req, res) => {
await db.deleteArticle(req.params.id);
res.status(204).end(); // no body
});
The cardinal rule of the response object is that you end the cycle exactly once. res.send, res.json, res.sendStatus, res.redirect, and res.end all finalize the response and flush headers. Calling any second terminator throws:
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
Warning: This usually happens when a branch sends a response but forgets to
return. Alwaysreturn res.status(...)...from guard clauses so execution stops there.
Chaining response methods
Methods that configure the response (status, set, type, links, vary, cookie, location) all return res, so you compose them fluently and finish with one terminator. The terminators themselves return res too, but nothing should follow them.
app.get("/profile", (req, res) => {
res
.status(200)
.set("Cache-Control", "private, max-age=60")
.type("json")
.json({ name: "Ada" });
});
This reads as a single declarative statement: status, then headers, then body. In Express 5 the API and chaining behavior are unchanged from 4.x; the main 5.x difference relevant here is that res.redirect("back") and res.location("back") no longer support the magic "back" keyword — pass an explicit URL instead.
Best Practices
- Use
res.jsonfor data APIs and reserveres.sendfor HTML or pre-formatted strings. - Always pair
res.status(code)with a terminator like.json()or.end()— status alone leaves the request hanging. - End the response exactly once;
returnfrom any branch that sends so later code cannot send again. - Set
Content-Typeexplicitly withres.type()when serving text to avoid thetext/htmlXSS default. - Use
res.sendStatus(code)for plain status replies andres.status(204).end()for empty bodies. - Stage headers and status before the body, taking advantage of chaining for readable handlers.