How to rotate user agents per request in Node.js
If you are cycling through User-Agent strings to spread your requests out and the target still flags them, the problem is usually not the rotation. It is that the rotated User-Agent arrives next to headers that contradict it: a Chrome UA paired with Firefox's Accept order, or Sec-Ch-Ua client hints that name a different browser than the UA string does. A detector that compares the User-Agent against the rest of the header set reads that mismatch as automation, and rotating the UA alone makes it worse, because each request now carries a fresh inconsistency.
The fix is to rotate a pool of full browser identities rather than bare strings, and to send the header set that the chosen browser would actually send. Each request picks one identity, then emits a User-Agent plus an Accept, Accept-Language, Accept-Encoding, and (for Chromium) Sec-Ch-Ua set that all agree with it. It comes to about 75 lines of Node.js with intoli/user-agents for the weighted UA pool and a small lookup that pairs each browser family with its matching headers. Rotating identities does not grant authorization or bypass a site's policy; it only keeps your authorized requests from being flagged on a header inconsistency.
Key terms
- User-Agent. The header that names the browser, engine, and platform a request claims to come from, which servers read before deciding what to return.
- Header consistency. The property that the User-Agent and the rest of the headers (
Accept,Accept-Language,Sec-Ch-Ua) name the same browser family; a mismatch is a detection signal on its own. - Client hints (
Sec-Ch-Ua). Chromium headers that restate the browser brand and major version, which a Chrome UA is expected to send and a Firefox UA is expected to omit. - Weighted pool. A set of User-Agent strings sampled by real-world frequency, so common desktop browsers appear more often than rare ones, instead of a uniform random pick.
Here is what the script does:
- Build a pool of desktop browser identities with intoli/user-agents, which samples User-Agent strings weighted by real traffic frequency and returns a full object with platform and vendor, not just the string.
- Read the browser family from each chosen User-Agent by checking the discriminating token in a fixed order, then look the header profile up in a table keyed by family.
- Assemble a header set that matches the family: the
Accept,Accept-Language, andAccept-Encodingvalues that browser sends, plusSec-Ch-Uaclient hints for Chromium and none for Firefox. - Send one self-consistent identity per request, rotating to the next on each call.
The complete script
// rotate-user-agents.mjs
import UserAgent from 'user-agents'
/* One header profile per browser family. The User-Agent picks the family,
and these headers are the ones that family actually sends. Keeping them
together is the point: a Chrome UA must not arrive with Firefox headers. */
const HEADER_PROFILES = {
chrome: {
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
acceptLanguage: 'en-US,en;q=0.9',
acceptEncoding: 'gzip, deflate, br, zstd',
// Client hints restate the brand + major version. Chromium sends these.
secChUa: '"Chromium";v="126", "Google Chrome";v="126", "Not.A/Brand";v="24"',
secChUaMobile: '?0',
secChUaPlatform: '"Windows"'
},
firefox: {
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
acceptLanguage: 'en-US,en;q=0.5',
acceptEncoding: 'gzip, deflate, br',
// Firefox does not send Sec-Ch-Ua. Adding it here is itself a tell.
secChUa: null
}
}
// Resolve the family by its discriminating token, checked in order. Chrome's
// UA also contains "Safari" and "AppleWebKit", so a single naive test is wrong;
// check Firefox/ then Chrome/, and return null for anything else.
const familyOf = (uaString) => {
if (uaString.includes('Firefox/')) return 'firefox'
if (uaString.includes('Chrome/')) return 'chrome'
return null
}
// intoli/user-agents samples by real-world frequency. Constrain to desktop
// Chrome and Firefox so every pick has a profile in HEADER_PROFILES.
const source = new UserAgent([
/Chrome|Firefox/,
{ deviceCategory: 'desktop' }
])
// Build the matching header set for one chosen identity.
const headersFor = (uaString) => {
const family = familyOf(uaString)
if (!family) return null // unknown family: skip rather than send a guess
const profile = HEADER_PROFILES[family]
const headers = {
'User-Agent': uaString,
'Accept': profile.accept,
'Accept-Language': profile.acceptLanguage,
'Accept-Encoding': profile.acceptEncoding
}
if (profile.secChUa) {
headers['Sec-Ch-Ua'] = profile.secChUa
headers['Sec-Ch-Ua-Mobile'] = profile.secChUaMobile
headers['Sec-Ch-Ua-Platform'] = profile.secChUaPlatform
}
return headers
}
// One self-consistent identity per request, fresh on each call.
const urls = [
'https://httpbin.org/headers',
'https://httpbin.org/headers',
'https://httpbin.org/headers'
]
for (const url of urls) {
const headers = headersFor(source().toString())
if (!headers) continue // no profile for this family: skip, do not guess
const res = await fetch(url, { headers })
const body = await res.json()
console.log(body.headers['User-Agent'])
console.log(body.headers['Sec-Ch-Ua'] ?? '(no client hints)')
console.log('---')
}npm install user-agents
node rotate-user-agents.mjsWhat each step does
Keep one header profile per browser family. HEADER_PROFILES holds the Accept, Accept-Language, Accept-Encoding, and client-hint values that Chrome and Firefox each send. These are stored together so the rotation does not split a User-Agent from its matching headers. The Chrome profile carries Sec-Ch-Ua; the Firefox profile sets it to null, because Firefox does not send client hints and adding them would be a mismatch. Two static profiles cover the desktop case here; when you need header sets generated across many browser and version combinations, the canonical implementation is Apify's header-generator, which samples matching headers from a network trained on real traffic.
Resolve the family by an ordered token check, then key into the profile table. familyOf checks for Firefox/ before Chrome/, because a Chrome User-Agent also contains the tokens Safari and AppleWebKit, so a single naive test reads the family wrong and pairs a real Chrome UA with Firefox headers, the exact failure this page exists to prevent. The resolved family then indexes HEADER_PROFILES. An unrecognized family returns null and the request is skipped rather than sent with a guessed header set.
Sample the pool by real frequency. new UserAgent([/Chrome|Firefox/, { deviceCategory: 'desktop' }]) constrains intoli/user-agents to desktop Chrome and Firefox, the two families with a profile. The library weights its picks by real-world traffic frequency, so common browsers appear more often than rare ones. Calling source() returns a fresh identity object and .toString() gives its User-Agent string.
Assemble and send one identity per request. headersFor builds the full header set for the chosen User-Agent and attaches the client hints only when the profile defines them. The loop calls it once per URL, so each request carries a User-Agent and a header set that agree with each other, and the next request rotates to a new pick.
Gotchas
A rotated User-Agent next to fixed headers is worse than no rotation.
- Issue: changing only the
User-AgentwhileAccept,Accept-Language, andSec-Ch-Uastay constant means each new UA contradicts the same stale header set, and a detector that cross-checks them flags each one. - Fix: rotate the whole identity. Pick the User-Agent and its matching profile together with
headersFor(source().toString()), so the headers change with the UA.
- Issue: changing only the
Substring matching picks the wrong family.
- Issue: a Chrome User-Agent contains the literal tokens
SafariandAppleWebKit, so a naiveuaString.includes('Safari')classifies Chrome as Safari and attaches the wrong header profile. - Fix: check the discriminating token in order,
Firefox/thenChrome/, asfamilyOfdoes, and returnnullfor anything outside the families you have a profile for.
- Issue: a Chrome User-Agent contains the literal tokens
Sending Sec-Ch-Ua with a Firefox User-Agent.
- Issue: Firefox does not emit
Sec-Ch-Ua,Sec-Ch-Ua-Mobile, orSec-Ch-Ua-Platform, so a Firefox UA arriving with those headers is an immediate inconsistency. - Fix: gate the client hints on
profile.secChUa, which isnullfor Firefox, so they are attached only for Chromium identities.
- Issue: Firefox does not emit
The client-hint version drifts from the User-Agent version.
- Issue: the
Sec-Ch-Uabrand list names a major version, and if you bump the Chrome version in the User-Agent string without updatingv="126"in the profile, the two disagree. - Fix: keep the major version in
secChUain step with the Chrome version your User-Agent pool reports, and re-check both when you refresh the pool.
- Issue: the
Header rotation does not change the TLS fingerprint.
- Issue: Node's
fetchsends one TLS ClientHello regardless of the User-Agent, so a site fingerprinting JA3 or HTTP/2 settings sees the same network signature under every rotated identity. - Fix: for targets that fingerprint the transport, send the requests through a client that varies the TLS handshake, such as the curl-impersonate family, rather than relying on headers alone.
- Issue: Node's
The pool can drift out of date.
- Issue: intoli/user-agents rebuilds its frequency data on a schedule, and a months-old install can emit User-Agent versions that no current browser reports, which stands out against live traffic.
- Fix: update the package periodically so the version numbers in the pool track shipping browsers, and keep the
HEADER_PROFILESmajor versions aligned with it.
Use this when
You run authorized, permitted data collection (your own sites, a licensed API, or a target whose terms and robots rules allow it) and you are spreading requests across browser identities to avoid being flagged on a header inconsistency rather than to defeat a hard block.
Respect the site's robots.txt, terms of service, and rate limits before reaching for any of this.
Skip this when
Skip it when a single fixed User-Agent already returns the page (rotation adds nothing); when the block is the browser fingerprint itself rather than the headers (patch the browser, see How to patch headless Chrome to avoid detection); when the site fingerprints TLS or HTTP/2 (vary the transport with an impersonating client); and when the limit is per-IP rather than per-identity (rotate the network path through a proxy instead).