Simplescraper
Skip to content

How to rotate user agents per request in Node.js

How to rotate user agents per request in Node.js

Updated 2026-06-25 · 6 min read

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, and Accept-Encoding values that browser sends, plus Sec-Ch-Ua client hints for Chromium and none for Firefox.
  • Send one self-consistent identity per request, rotating to the next on each call.

The complete script

js
// 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('---')
}
bash
npm install user-agents
node rotate-user-agents.mjs

What 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-Agent while Accept, Accept-Language, and Sec-Ch-Ua stay 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.
  • Substring matching picks the wrong family.

    • Issue: a Chrome User-Agent contains the literal tokens Safari and AppleWebKit, so a naive uaString.includes('Safari') classifies Chrome as Safari and attaches the wrong header profile.
    • Fix: check the discriminating token in order, Firefox/ then Chrome/, as familyOf does, and return null for anything outside the families you have a profile for.
  • Sending Sec-Ch-Ua with a Firefox User-Agent.

    • Issue: Firefox does not emit Sec-Ch-Ua, Sec-Ch-Ua-Mobile, or Sec-Ch-Ua-Platform, so a Firefox UA arriving with those headers is an immediate inconsistency.
    • Fix: gate the client hints on profile.secChUa, which is null for Firefox, so they are attached only for Chromium identities.
  • The client-hint version drifts from the User-Agent version.

    • Issue: the Sec-Ch-Ua brand list names a major version, and if you bump the Chrome version in the User-Agent string without updating v="126" in the profile, the two disagree.
    • Fix: keep the major version in secChUa in step with the Chrome version your User-Agent pool reports, and re-check both when you refresh the pool.
  • Header rotation does not change the TLS fingerprint.

    • Issue: Node's fetch sends 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.
  • 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_PROFILES major 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).

Skip the code, just get the data

Simplescraper turns any website into structured data in seconds.