res is the outbox. Whatever we want to send back — JSON, HTML, a file, a redirect — we do it through res. In simple language: req is what came in, res is what we send out.
The golden rule: we can only send one response per request. Call res.send() twice and Express throws “Cannot set headers after they are sent.” Always return after sending.
.send() .json() .sendFile() .render()
.status() .sendStatus()
.set() .type() .cookie()
.redirect() .location()
res.send() — the universal sender
Sends whatever we give it and sets Content-Type based on the type.
app.get("/text", (req, res) => res.send("hello")); // text/html
app.get("/obj", (req, res) => res.send({ ok: true })); // application/json
app.get("/buf", (req, res) => res.send(Buffer.from("hi"))); // application/octet-stream
Express figures out the content type. For JSON specifically, prefer res.json().
res.json() — for APIs
app.get("/api/users/:id", (req, res) => {
res.json({ id: req.params.id, name: "Manish" });
});
Same as res.send() for objects, but it also handles null and primitives correctly as JSON. Use this for every JSON API endpoint — it’s clearer intent.
{ "id": "42", "name": "Manish" }
res.status() — set the status code
Returns res, so we chain it:
app.post("/users", (req, res) => {
if (!req.body.email) {
return res.status(400).json({ error: "email is required" });
}
const user = createUser(req.body);
res.status(201).json(user); // 201 Created
});
Defaults to 200. For empty responses, res.sendStatus(204) sets status AND sends the standard status text.
res.redirect() — send the browser elsewhere
app.get("/old-page", (req, res) => {
res.redirect(301, "/new-page"); // 301 = permanent
});
app.post("/login", (req, res) => {
// ...auth...
res.redirect("/dashboard"); // defaults to 302
});
301 is permanent (browser caches it forever), 302/303 are temporary. For POST-redirect-GET pattern, use 303.
res.sendFile() — stream a file from disk
const path = require("path");
app.get("/download/report.pdf", (req, res) => {
res.sendFile(path.join(__dirname, "files", "report.pdf"));
});
The path must be absolute (or pass { root: ... }). Express sets Content-Type based on file extension and streams it efficiently. For forced download:
app.get("/download/:file", (req, res) => {
res.download(path.join(__dirname, "files", req.params.file));
// Sets Content-Disposition: attachment so browser saves instead of viewing
});
res.set() — set headers
res.set("X-Powered-By", "Coffee");
res.set({ "Cache-Control": "no-store", "X-Request-Id": "abc123" });
// Shortcut for content type
res.type("text/csv");
res.type("json"); // application/json
Headers must be set before res.send(). After we send, headers are locked.
Cookies
res.cookie("session", "abc123", {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000, // 1 day in ms
});
res.clearCookie("session");
httpOnly prevents JS access (XSS protection), secure requires HTTPS, sameSite blocks CSRF.
Chaining — everything returns res
This is the Express magic — every setter returns res, so we chain:
res
.status(201)
.set("Location", `/users/${user.id}`)
.type("json")
.json(user);
Reads top-to-bottom like a recipe.
A realistic endpoint
app.post("/api/users", async (req, res, next) => {
try {
if (!req.body.email) {
return res.status(400).json({ error: "email required" });
}
const user = await db.createUser(req.body);
res
.status(201)
.set("Location", `/api/users/${user.id}`)
.json(user);
} catch (err) {
next(err);
}
});
Status, location header, JSON body — all in one chain. That’s res in a nutshell.