Content-Disposition: inline vs attachment, Explained

Content-Disposition tells a browser to show a response inline or download it as an attachment, and under what filename. Syntax, UTF-8 names, tested code.

By Simon Chiu · Updated

Content-Disposition is an HTTP response header that tells the browser whether to display the body in the window (inline) or save it as a file (attachment), and which filename to suggest.

It decides what happens when a user clicks a link to a screenshot or a PDF: does the image open in a tab, or does a file appear in Downloads? This entry is part of the screenshot glossary.

What it means in plain terms

The header has a type and optional parameters:

Content-Disposition: inline
Content-Disposition: inline; filename="report.pdf"
Content-Disposition: attachment; filename="report.pdf"
Content-Disposition: attachment; filename="resume.png"; filename*=UTF-8''r%C3%A9sum%C3%A9.png
  • inline: show it if you can. Images, PDFs, video and HTML render in the tab. The filename is used if the user then saves the file.
  • attachment: do not render it, save it. The browser starts a download.
  • filename: the suggested name, plain ASCII in quotes.
  • filename*: the same name in UTF-8, percent-encoded (RFC 6266 with RFC 8187 encoding). When both are present, modern browsers prefer filename*.

The header only says what to do with a body. The MIME type in Content-Type still says what the body is.

How the browser decides

flowchart TD
  A[Response arrives] --> B{Content-Disposition?}
  B -->|attachment| D["Download<br/>name from filename* or filename"]
  B -->|inline or missing| C{"Can the browser render<br/>this Content-Type?"}
  C -->|yes: image, PDF, HTML| E[Show in the tab]
  C -->|no| D
  F["a href download (same origin only)"] -.->|forces| D

The download attribute on a link can also force a download, but only for same-origin URLs. A link from your site to an image on an API domain or a CDN ignores it. That is why "add download to the link" often does nothing for screenshot URLs.

Testing it with Puppeteer, Playwright and RenderScreenshot

To see both behaviours, we served the same PNG from a small Node server, once as inline and once as attachment, with a UTF-8 filename:

// cd-server.mjs: serves the same PNG twice, once inline and once as an attachment.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';

const png = readFileSync(process.argv[2]);
const name = 'capture-résumé.png';
// ASCII fallback in filename, UTF-8 name in filename*.
const disposition = (type) =>
  `${type}; filename="capture-resume.png"; filename*=UTF-8''${encodeURIComponent(name)}`;

createServer((req, res) => {
  const type = req.url.startsWith('/download') ? 'attachment' : 'inline';
  res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Disposition': disposition(type) });
  res.end(png);
}).listen(8077);

Playwright accepts downloads by default. Navigation to an attachment rejects page.goto with "Download is starting", so wait for the download event at the same time:

import { chromium } from 'playwright';

const browser = await chromium.launch();
try {
  const page = await browser.newPage();
  const res = await page.goto('http://127.0.0.1:8077/view');
  console.log('inline:', res.status());

  const [download] = await Promise.all([
    page.waitForEvent('download'),
    page.goto('http://127.0.0.1:8077/download').catch(() => {}), // rejects: "Download is starting"
  ]);
  console.log('attachment saved as:', download.suggestedFilename());
  await download.saveAs(`./${download.suggestedFilename()}`);
} finally {
  await browser.close();
}

It printed attachment saved as: capture-résumé.png: Chromium used filename*, not the ASCII fallback.

Puppeteer has no download event, so tell Chrome where to save through the DevTools Protocol:

import puppeteer from 'puppeteer';
import { mkdir, readdir } from 'node:fs/promises';
import { resolve } from 'node:path';

const dir = resolve('downloads');
await mkdir(dir, { recursive: true });
const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  const cdp = await browser.target().createCDPSession();
  await cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: dir, eventsEnabled: true });
  const done = new Promise((r) => cdp.on('Browser.downloadProgress', (e) => e.state === 'completed' && r()));

  await page.goto('http://127.0.0.1:8077/download').catch((e) => console.log('goto:', e.message));
  await done;
  console.log('files:', await readdir(dir));
} finally {
  await browser.close();
}

Output: goto: net::ERR_ABORTED at http://127.0.0.1:8077/download, then files: [ 'capture-résumé.png' ]. The ERR_ABORTED is expected: the navigation became a download.

RenderScreenshot answers a binary capture with inline, so a link to the image or PDF opens it in the tab:

HTTP/1.1 200 OK
Content-Type: image/png
Content-Disposition: inline; filename="screenshot.png"
X-Cache-URL: https://cdn.renderscreenshot.com/...

A PDF arrives as inline; filename="screenshot.pdf". If your users need a "Download" button with a named file, serve the capture from your own route and set attachment there, for example in Rails:

# app/controllers/reports_controller.rb
def download
  pdf = ScreenshotClient.pdf_for(report_url(params[:id])) # your wrapper around the API call
  send_data pdf, type: 'application/pdf', disposition: 'attachment',
                 filename: "report-#{params[:id]}.pdf"
end

Rails' send_data writes both filename and filename*. The full list of response headers is in response headers, and PDF output in PDF options.

Tested on 2026-10-15 on macOS 27 (arm64) with Node 22.18.0, [email protected] (Chrome 148.0.7778.97, headless: 'shell') and [email protected]. The Rails controller is a fragment (ScreenshotClient stands for your own code) and was not run.

Common mistakes

  • Non-ASCII in filename. Raw UTF-8 in the quoted filename is not valid and different browsers read it differently. Put an ASCII fallback in filename and the real name in filename*.
  • Unescaped quotes or user input in the name. A name with " or a newline can break the header. Build it from a safe slug, not raw user text.
  • Relying on download across origins. It is ignored for other origins. Set the header on the server, or proxy the file.
  • Expecting attachment to stop caching or access. The header controls display, not who can fetch the URL or for how long. Use cache headers and signed URLs for that.

To render PDFs and images without running a browser, create a free account.

Frequently asked questions

What is the default if there is no Content-Disposition header?
The browser treats the response as inline. It displays it if it can render the Content-Type (images, PDF, HTML) and downloads it otherwise.
Why does the HTML download attribute not work on my link?
Browsers ignore the download attribute for links to another origin. A link to an API or CDN on another domain needs Content-Disposition: attachment from that server, or a same-origin proxy that adds it.
How do I send a filename with accents or non-Latin characters?
Send two parameters: filename with an ASCII fallback, and filename* with the UTF-8 name percent-encoded, for example filename*=UTF-8''r%C3%A9sum%C3%A9.pdf. Browsers that understand filename* use it.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.