A user agent string is the text a browser sends in the User-Agent HTTP header, and exposes as navigator.userAgent, to tell servers which browser, engine and operating system is making the request.
Servers use it to pick a mobile or desktop template, to log traffic and to spot bots. For screenshots, it decides which version of the page you capture, and it is the first place where a headless browser gives itself away. This entry is part of the screenshot and rendering glossary.
A plain explanation
Every request your browser makes carries a short self-description. It was meant to be simple, "I am browser X version Y on system Z", but decades of servers sniffing for specific browsers turned it into a pile of tokens that each browser copies from the others so it is not served a worse page.
Anatomy of the string
This is the string Puppeteer 24's default headless Chrome sent to our test server on an Apple silicon Mac:
Only one token carries real information: HeadlessChrome/148. The rest is fixed. Since Chrome's user agent reduction, the platform reads Intel Mac OS X 10_15_7 on every Mac, and the version is 148.0.0.0 instead of the real build.
What the server and the page saw
We pointed four setups at a local server that logged the header, and read navigator.userAgent and the full version from User-Agent Client Hints (navigator.userAgentData.getHighEntropyValues):
| Setup | Version in the string | Full version from client hints |
|---|---|---|
| Puppeteer, default headless | HeadlessChrome/148.0.0.0 |
148.0.7778.97 |
Puppeteer, headless: 'shell' |
HeadlessChrome/148.0.7778.97 |
148.0.7778.97 |
Puppeteer, setUserAgent() to a custom bot string |
custom string | none (userAgentData.brands is an empty list) |
Playwright, userAgent context option, same string |
custom string | 156.0.8078.4 (still reported) |
Tested on 2026-10-15 with Node 22.18, Puppeteer 24.43.1 (Chrome 148) and Playwright 1.64.1 (Chromium 156) on macOS. In every row the header and navigator.userAgent matched.
Two lessons. The older headless shell still sends an unreduced version, so it does not look like any shipping Chrome. And an override is not complete: in Playwright the page could still read the real Chromium version through client hints, a mismatch that a script can test for.
How it works technically
The browser builds the string once and sends it on every HTTP request, including images and scripts. JavaScript reads the same value. Since Chrome's reduction, detailed data (full version, exact OS version, device model) moved to client hints: low-detail Sec-CH-UA headers on every request, and the rest only to servers that ask with Accept-CH, or to scripts that call getHighEntropyValues. Overriding the string through the DevTools Protocol changes the header and navigator.userAgent; whether client hints change too depends on the metadata the tool sends with it.
How to set it in Puppeteer, Playwright and RenderScreenshot
Puppeteer (the options-object form; set it before navigation):
import puppeteer from 'puppeteer'; const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setUserAgent({ userAgent: 'Mozilla/5.0 (compatible; AcmeArchiver/2.1; +https://example.com/bot)' }); await page.goto('https://example.com'); console.log(await page.evaluate(() => navigator.userAgent)); } finally { await browser.close(); }
Playwright sets it per context:
import { chromium } from 'playwright'; const browser = await chromium.launch(); try { const context = await browser.newContext({ userAgent: 'Mozilla/5.0 (compatible; AcmeArchiver/2.1; +https://example.com/bot)' }); const page = await context.newPage(); await page.goto('https://example.com'); console.log(await page.evaluate(() => navigator.userAgent)); } finally { await browser.close(); }
RenderScreenshot takes browser.user_agent, or user_agent as a query parameter. A device preset sets that device's own browser string for you.
curl --get https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ --data-urlencode 'url=https://example.com' \ --data-urlencode 'user_agent=Mozilla/5.0 (compatible; AcmeArchiver/2.1; +https://example.com/bot)' \ --output page.png
import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); const image = await client.take( TakeOptions.url('https://example.com').userAgent('Mozilla/5.0 (compatible; AcmeArchiver/2.1; +https://example.com/bot)'), );
The Puppeteer and Playwright scripts ran in the same test. The RenderScreenshot snippets follow the API docs and were not run for this page. The custom user agent guide lists exactly what the API option changes.
Common mistakes
- Expecting a mobile layout from a phone string. Responsive CSS reads the viewport, not the user agent. Set both, or use a device preset.
- Parsing the version for features. The reduced string hides minor versions; use feature detection.
- Copying a real Chrome string to hide automation. Bot detection checks far more than this header, and a stale version number stands out.
- Using an anonymous string for a crawler. A name and a contact URL, as in the examples, lets site owners identify and allow you.
Related terms
- Headless Chrome: the
HeadlessChrometoken and other differences from desktop Chrome. - Bot detection: what sites check besides the user agent.
- Accept-Language: the other header that changes which page you get.
To capture a page as a specific device or crawler, get a free API key.