Redirects and Downloads

Redirects, Downloads and File Streaming

res.redirect() sets Location, picks a status and writes a short body for clients that show one. The default is 302; for any other, the status comes first in Express 5 24,430 — the Express 4 order res.redirect(url, status) was removed along with the magic string 'back'.

Redirecting, offering a download, and streaming generated bytesJavaScript
app.get('/old/books', (req, res) => res.redirect(301, '/books'));    // permanent
app.get('/back', (req, res) => res.redirect(req.get('Referrer') || '/'));
app.get('/export.csv', (req, res) => {
  res.download(path.join(__dirname, 'books.csv'), 'catalog-2026.csv', (err) => {
    if (err && !res.headersSent) res.status(404).end();   // missing or unreadable
  });
});
app.get('/stream.csv', (req, res) => {
  res.type('csv').attachment('big.csv');
  fs.createReadStream(path.join(__dirname, 'books.csv')).pipe(res);
});
Output
$ curl -s -D - .../old/books
HTTP/1.1 301 Moved Permanently    Location: /books
Moved Permanently. Redirecting to /books
$ curl -s -D - -H 'Referer: https://example.com/books?page=3' .../back
HTTP/1.1 302 Found    Location: https://example.com/books?page=3
GET /export.csv                             GET /stream.csv
Content-Disposition: attachment;            Content-Disposition: attachment;
  filename="catalog-2026.csv"                 filename="big.csv"
Accept-Ranges: bytes                        Content-Type: text/csv; charset=utf-8
Cache-Control: public, max-age=0            Transfer-Encoding: chunked
Content-Length: 47

The /back route is the documented replacement for res.redirect('back'), and it needs a warning: Referrer is attacker-controlled, so following it blindly is an open-redirect vulnerability — accept it only when it is same-origin. After a POST, redirect with 303 so the browser follows with GET.

res.download() is res.sendFile() plus a Content-Disposition naming the saved file. sendFile streams from disk with Content-Type from the extension, Last-Modified, ETag and Accept-Ranges: bytes, so range requests and video seeking work; it needs an absolute path or a root option, and will not escape root — though symbolic links inside root still resolve, so validate anyway.

When the bytes are not a file on disk, pipe a stream into res, which is a writable stream and handles back-pressure for you. The missing Content-Length on the right is a stream's signature: Express cannot know the size in advance, so Node falls back to chunked encoding, which costs the ETag and the progress bar.