node-wreq is a thin Node.js wrapper around 0x676e67's wreq —
a Rust HTTP client exposing its full power to JavaScript.
Use it when you need low-level control over the network layer: TLS configuration, transport fingerprinting, browser impersonation, or fine-grained HTTP/WebSocket behavior that standard Node.js clients simply don't expose.
Tip
Node.js ships with a built-in https module, and the ecosystem offers popular clients like axios, got, and node-fetch — but all of them are built on top of OpenSSL via Node's tls module, which exposes no control over low-level TLS handshake parameters. This makes it fundamentally impossible to emulate real browser network behavior from pure JavaScript.
HTTP/1 over TLS
Node.js HTTP clients normalize headers to lowercase internally, which is compliant with HTTP/2 semantics but breaks compatibility with some WAFs that enforce case-sensitive header validation on HTTP/1 requests. This wrapper preserves header case exactly as specified, preventing requests from being silently rejected.
HTTP/2 over TLS
Fingerprints like JA3, JA4, and Akamai HTTP/2 are derived from the specifics of the TLS handshake and HTTP/2 SETTINGS frames — cipher suite ordering, TLS extensions, ALPN values, HPACK header compression parameters, and more. Node.js exposes none of these through its
tlsorhttp2APIs. You simply cannot spoof them from JS land, no matter the library. This package solves that at the native layer, giving you fine-grained control over TLS and HTTP/2 extensions to precisely match real browser behavior.Device Emulation
Because TLS and HTTP/2 fingerprints evolve slowly relative to browser release cycles, a single fingerprint profile often covers many browser versions. 100+ pre-built browser device profiles are bundled, so you don't have to figure out the right combination of settings yourself.
TLS and HTTP/2 fingerprinting is actively used by major bot protection and WAF providers — including Cloudflare Bot Management, AWS WAF (Bot Control + CloudFront JA3 headers), Google Cloud Armor, Akamai (which maintains its own HTTP/2 fingerprint format on top of JA3/JA4), ServicePipe (a Russian DDoS protection and WAF provider), and various specialized anti-bot services like DataDome and PerimeterX. Correctly emulating a browser's TLS handshake and HTTP/2 SETTINGS frames is a hard requirement to get past these layers undetected.
TLS Fingerprinting with JA3 and JA3S
JA3/JA4 Fingerprint — Cloudflare Bot Solutions
TLS Fingerprinting: How It Works & How to Bypass It
Note
This only covers the transport layer. It does not help bypass JavaScript-based challenges (Cloudflare Turnstile, Akamai sensor data, Kasada, etc.), CAPTCHA, or behavioral analysis — those require a different approach entirely
npm install node-wreqNode.js 20+ is required.
🌐 fetch
🧩 client — shared defaults, reusable config.
🪝 hooks — request lifecycle, dynamic auth, retries, etc.
🧪 networking / transport knobs — TLS, HTTP/1, HTTP/2 options; header ordering, mTLS and custom CAs; DNS controls.
import{fetch}from'node-wreq';constresponse=awaitfetch('https://httpbin.org/get',{browser: 'chrome_137',});console.log(response.status);console.log(awaitresponse.json());If you keep repeating config, move to a client:
import{createClient}from'node-wreq';constclient=createClient({baseURL: 'https://httpbin.org',browser: 'chrome_137',headers: {'x-client': 'node-wreq',},retry: 2,});constresponse=awaitclient.fetch('/anything',{query: {from: 'client'},});console.log(response.status);console.log(awaitresponse.json());🌐 fetch · ↑
import{fetch}from'node-wreq';constresponse=awaitfetch('https://httpbin.org/get',{browser: {profile: 'firefox_151',platform: 'linux',http2: true,headers: true,},query: {source: 'node-wreq',debug: true,},timeout: 15_000,});constbody=awaitresponse.json();console.log(response.ok);console.log(body.args);import{fetch}from'node-wreq';constresponse=awaitfetch('https://api.example.com/items',{method: 'POST',browser: 'chrome_137',headers: {'content-type': 'application/json',},body: JSON.stringify({name: 'example',enabled: true,}),throwHttpErrors: true,});console.log(awaitresponse.json());FormData request bodies work like fetch. By default, node-wreq generates a
----WebKitFormBoundary... boundary so the multipart envelope matches browser traffic as well as
the TLS and HTTP fingerprints do. File parts are encoded by native wreq multipart support and
streamed with backpressure instead of buffering the complete form in memory.
constformData=newFormData();formData.append('alpha','1');formData.append('upload',newFile(['hello'],'hello.txt',{type: 'text/plain'}));constresponse=awaitfetch('https://api.example.com/upload',{method: 'POST',body: formData,// Optional: multipartBoundary: '----my-explicit-boundary',});console.log(awaitresponse.json());import{Request,fetch}from'node-wreq';constrequest=newRequest('https://httpbin.org/post',{method: 'POST',headers: {'content-type': 'application/json',},body: JSON.stringify({via: 'Request'}),});constresponse=awaitfetch(request,{browser: 'chrome_137',});console.log(awaitresponse.json());fetch() returns a fetch-style Response, plus extra metadata under response.wreq.
constresponse=awaitfetch('https://example.com',{browser: 'chrome_137',});console.log(response.status);console.log(response.headers.get('content-type'));console.log(response.wreq.cookies);console.log(response.wreq.setCookies);console.log(response.wreq.timings);console.log(response.wreq.redirectChain);If you need a Node stream instead of a WHATWG stream:
constreadable=response.wreq.readable();readable.pipe(process.stdout);🧩 client · ↑
Use createClient(...) when requests share defaults. A client also owns reusable native connection
and TLS-session pools; it is not just a JavaScript defaults wrapper.
baseURL- browser profile
- headers
- proxy
- timeout
- hooks
- retry policy
- cookie jar
import{createClient}from'node-wreq';constclient=createClient({baseURL: 'https://api.example.com',browser: 'chrome_137',timeout: 10_000,headers: {authorization: `Bearer ${process.env.API_TOKEN}`,},retry: {limit: 2,statusCodes: [429,503],},});constusers=awaitclient.get('/users');console.log(awaitusers.json());constcreated=awaitclient.post('/users',JSON.stringify({email: 'user@example.com'}),{headers: {'content-type': 'application/json',},});console.log(created.status);// Optional for long-lived processes; pooled resources are also released after GC.client.close();constbase=createClient({baseURL: 'https://api.example.com',browser: 'chrome_137',});constadmin=base.extend({headers: {authorization: `Bearer ${process.env.ADMIN_TOKEN}`,},});awaitbase.get('/health');awaitadmin.get('/admin/stats');🎭 browser profiles · ↑
Inspect the available profiles at runtime:
import{getProfiles}from'node-wreq';console.log(getProfiles());There is also BROWSER_PROFILES if you want the generated list directly.
Typical profiles include browser families like:
- Chrome
- Edge
- Firefox
- Safari
- Opera
- OkHttp
The current upstream snapshot includes the newest profiles through chrome_149, edge_148,
firefox_151, opera_131, and safari_26_4. When browser is omitted, chrome_149 is used.
You can also select a platform explicitly or ask upstream to choose a profile automatically:
awaitfetch('https://example.com',{browser: {profile: 'chrome_149',platform: 'windows',// Optional component switches from upstream's Emulation builder:http2: true,headers: true,},});awaitfetch('https://example.com',{browser: {mode: 'random'},});awaitfetch('https://example.com',{// Uses current browser-market-share weights and valid browser/platform pairings.browser: {mode: 'weighted-random'},});Set browser.http2 to false when you want the selected TLS/profile identity without its
HTTP/2 fingerprint settings. Set browser.headers to false to omit the profile's default
headers and header ordering; the top-level disableDefaultHeaders option remains a convenient
request-wide equivalent for the latter.
🪝 hooks · ↑
Hooks are the request pipeline.
Available phases:
initbeforeRequestafterResponsebeforeRetrybeforeErrorbeforeRedirect
import{createClient}from'node-wreq';constclient=createClient({baseURL: 'https://example.com',retry: {limit: 2,statusCodes: [429,503],backoff: ({ attempt })=>attempt*250,},hooks: {init: [({ options, state })=>{options.query={ ...options.query,source: 'hook-init'};state.startedAt=Date.now();},],beforeRequest: [({ request, options, state })=>{request.headers.set('x-trace-id',crypto.randomUUID());request.headers.set('authorization',`Bearer ${getAccessToken()}`);options.proxy=pickProxy();state.lastProxy=options.proxy;},],beforeRetry: [({ options, attempt, error, state })=>{options.proxy=pickProxy(attempt);console.log('retrying',{
attempt,proxy: options.proxy,previousProxy: state.lastProxy,
error,});},],beforeError: [({ error, state })=>{error.message=`[trace=${String(state.startedAt)}] ${error.message}`;returnerror;},],},});import{Response,fetch}from'node-wreq';constresponse=awaitfetch('https://example.com/account',{hooks: {afterResponse: [async({ response })=>{if(response.status===401){returnnewResponse(JSON.stringify({guest: true}),{status: 200,headers: {'content-type': 'application/json',},url: response.url,});}},],},});console.log(awaitresponse.json());awaitfetch('https://example.com/login',{hooks: {beforeRedirect: [({ request, nextUrl, redirectCount })=>{request.headers.set('x-redirect-hop',String(redirectCount));request.headers.set('x-next-url',nextUrl);},],},});Rule of thumb:
- use hooks for dynamic behavior
- use client defaults for static behavior
🍪 cookies and sessions · ↑
node-wreq does not force a built-in cookie store.
You provide a cookieJar with two methods:
getCookies(url)setCookie(cookie, url)
That jar can be:
- in-memory
tough-cookie- Redis-backed
- DB-backed
- anything else that matches the interface
import{fetch,websocket}from'node-wreq';constjarStore=newMap<string,string>();constcookieJar={getCookies(){return[...jarStore.entries()].map(([name,value])=>({
name,
value,}));},setCookie(cookie: string){const[pair]=cookie.split(';');const[name,value='']=pair.split('=');jarStore.set(name,value);},};awaitfetch('https://example.com/login',{ cookieJar });awaitfetch('https://example.com/profile',{ cookieJar });awaitwebsocket('wss://example.com/ws',{ cookieJar });npm install tough-cookieimport{CookieJarasToughCookieJar}from'tough-cookie';import{createClient}from'node-wreq';consttoughJar=newToughCookieJar();constcookieJar={asyncgetCookies(url: string){constcookies=awaittoughJar.getCookies(url);returncookies.map((cookie)=>({name: cookie.key,value: cookie.value,}));},asyncsetCookie(cookie: string,url: string){awaittoughJar.setCookie(cookie,url);},};constclient=createClient({browser: 'chrome_137',
cookieJar,});awaitclient.fetch('https://example.com/login');awaitclient.fetch('https://example.com/profile');import{fetch}from'node-wreq';constresponse=awaitfetch('https://example.com/login',{ cookieJar });console.log(response.wreq.setCookies);console.log(response.wreq.cookies);🔁 redirects and retries · ↑
Both are opt-in controls on top of the normal request pipeline.
constresponse=awaitfetch('https://example.com/login',{redirect: 'manual',});console.log(response.status);console.log(response.headers.get('location'));console.log(response.redirected);Modes:
follow- default redirect followingmanual- return the redirect response as-iserror- throw on the first redirect
Useful redirect facts:
response.wreq.redirectChainrecords followed hops301/302rewritePOSTtoGET303rewrites toGETunless current method isHEAD307/308preserve method and bodyauthorizationis stripped on cross-origin redirect
constresponse=awaitfetch('https://example.com',{retry: 2,});constresponse=awaitfetch('https://example.com',{retry: {limit: 3,statusCodes: [429,503],backoff: ({ attempt })=>attempt*500,},});import{TimeoutError,fetch}from'node-wreq';constresponse=awaitfetch('https://example.com',{retry: {limit: 5,shouldRetry: ({ error, response })=>{if(response?.status===429){returntrue;}returnerrorinstanceofTimeoutError;},},});Defaults:
- retry is off unless you enable it
- default retry methods are
GETandHEAD - default status codes include
408,425,429,500,502,503,504 - default error codes include
ECONNRESET,ECONNREFUSED,ETIMEDOUT,ERR_TIMEOUT
📊 observability · ↑
Two main surfaces:
response.wreq.timingsonStats(stats)
awaitfetch('https://example.com',{onStats: ({ attempt, timings, response, error })=>{console.log({
attempt,wait: timings.wait,total: timings.total,status: response?.status,
error,});},});constresponse=awaitfetch('https://example.com',{browser: 'chrome_137',});console.log(response.wreq.timings);Current timings are wrapper-level timings that are still useful in practice:
- request start
- response available
- total time when body consumption is known
🚨 error handling · ↑
Main error classes:
RequestErrorHTTPErrorTimeoutErrorAbortErrorWebSocketError
Typical patterns:
import{HTTPError,TimeoutError,fetch}from'node-wreq';try{awaitfetch('https://example.com',{timeout: 1_000,throwHttpErrors: true,});}catch(error){if(errorinstanceofTimeoutError){console.error('request timed out');}elseif(errorinstanceofHTTPError){console.error('bad status',error.statusCode);}else{console.error(error);}}🔌 websockets · ↑
You can use either:
await websocket(url, init?)new WebSocket(url, init?)
import{websocket}from'node-wreq';constsocket=awaitwebsocket('wss://echo.websocket.events',{browser: 'chrome_137',protocols: ['chat'],});socket.addEventListener('message',(event)=>{console.log('message:',event.data);});socket.send('hello');import{WebSocket}from'node-wreq';constsocket=newWebSocket('wss://example.com/ws',{binaryType: 'arraybuffer',});awaitsocket.opened;socket.onmessage=(event)=>{if(event.datainstanceofArrayBuffer){console.log(newUint8Array(event.data));}};socket.send(newUint8Array([1,2,3]));socket.close(1000,'done');Useful when you want shared defaults like browser, proxy, or cookies:
constclient=createClient({browser: 'chrome_137',cookieJar: yourCookieJar,});constsocket=awaitclient.websocket('wss://example.com/ws');Notes:
- cookies from
cookieJarare sent during handshake - duplicate subprotocols are rejected
httpVersion: '1.1' | '2'explicitly selects the handshake HTTP version
🧪 networking / transport knobs · ↑
This is the "transport nerd" section.
Everything else here is for debugging request shape, fingerprint-sensitive targets, or testing transport hypotheses.
constresponse=awaitfetch('https://httpbin.org/anything',{browser: 'chrome_137',proxy: 'http://username:password@proxy.example.com:8080',timeout: 10_000,});If you want to bypass env/system proxy detection for a specific request, use proxy: false:
awaitfetch('https://example.com',{proxy: false,});By default, node-wreq may apply profile-appropriate default headers.
disableDefaultHeaders: true disables those browser/profile preset headers only.
That means it turns off headers injected by the selected browser emulation, such as:
user-agentacceptaccept-languagesec-ch-uasec-ch-ua-mobilesec-ch-ua-platformsec-fetch-destsec-fetch-modesec-fetch-sitepriority
The exact set varies by profile.
It does not disable protocol or transport-level headers that may still appear automatically, such as:
hostaccept-encodingwhencompressis enabledcontent-lengthwhen the request body requires itcontent-typegenerated by the runtime for bodies likeFormData
It also does not remove headers you set explicitly yourself.
If you want full manual control:
awaitfetch('https://example.com',{disableDefaultHeaders: true,headers: {accept: '*/*','user-agent': 'custom-client',},});For example, with browser: 'chrome_137', the default request would normally include Chrome-like sec-ch-*, sec-fetch-*, user-agent, accept, and accept-language headers. With disableDefaultHeaders: true, those browser preset headers are skipped, while transport headers like host and accept-encoding may still be present.
Use tuples when header order matters.
Tuple headers also preserve the original header names exactly as you wrote them on the wire:
awaitfetch('https://example.com',{headers: [['x-lower','one'],['X-Mixed','two'],],});For example, this will preserve both the tuple order and the exact x-lower / X-Mixed casing you passed.
If a browser preset gets you close but not all the way there:
awaitfetch('https://example.com',{browser: 'chrome_137',tlsOptions: {greaseEnabled: true,keyShares: ['X25519_MLKEM768','X25519','P256'],},http1Options: {writev: true,},http2Options: {adaptiveWindow: false,maxConcurrentStreams: 64,},});Use these only when:
- a target is still picky after choosing a browser profile
- you are comparing transport behavior
- you want to debug fingerprint mismatches
Custom TLS/HTTP1/HTTP2 options are overlaid on the selected browser profile. Unspecified profile settings remain intact.
createClient() reuses the underlying native wreq::Client, including HTTP keep-alive connections
and TLS sessions. Builder-affecting per-request overrides automatically get an isolated client
variant so a proxy, DNS, TLS, or browser change cannot reuse an incompatible pool.
constclient=createClient({baseURL: 'https://api.example.com',poolIdleTimeout: 90_000,poolMaxIdlePerHost: 8,poolMaxSize: 128,tlsSessionCacheCapacity: 8,});awaitclient.get('/account',{// Requests in different groups never share pooled connections.connectionGroup: 'account-session',});constresponse=awaitclient.get('/health',{// Static form: discard this request's connection after the response.forbidConnectionReuse: true,});// Conditional form: call before consuming the body.response.wreq.forbidConnectionReuse();client.close();Set poolIdleTimeout: false to disable idle expiry. connectionGroup accepts a string or a
non-negative integer.
Use tlsIdentity for client certificate authentication and ca for a custom trust store:
import{fetch}from'node-wreq';import{readFileSync}from'node:fs';awaitfetch('https://mtls.example.com',{tlsIdentity: {cert: readFileSync('./client-cert.pem'),key: readFileSync('./client-key.pem'),},ca: {cert: readFileSync('./ca.pem'),includeDefaultRoots: false,},});PKCS#12 / PFX identities are also supported:
awaitfetch('https://mtls.example.com',{tlsIdentity: {pfx: readFileSync('./client-identity.p12'),passphrase: 'secret',},ca: {cert: readFileSync('./ca.pem'),includeDefaultRoots: false,},});For TLS diagnostics, you can ask for peer certificate metadata on the response or write TLS session keys to a file for tools like Wireshark:
constresponse=awaitfetch('https://mtls.example.com',{tlsDebug: {peerCertificates: true,keylog: {path: '/tmp/node-wreq.keys',},},});console.log(response.wreq.tls?.peerCertificate);console.log(response.wreq.tls?.peerCertificateChain);Unsafe TLS overrides are separate and explicit:
awaitfetch('https://staging.internal.example',{tlsDanger: {certVerification: false,verifyHostname: false,sni: false,},});Compression is enabled by default.
That includes gzip, br, deflate, and zstd response decoding when the server supports them.
Disable it if you need stricter control over response handling:
awaitfetch('https://example.com/archive',{compress: false,});Use dns.hosts to pin hostnames to specific IPs, or dns.servers to send lookups through specific nameservers:
awaitfetch('https://api.internal.test/health',{dns: {servers: ['1.1.1.1','8.8.8.8'],hosts: {'api.internal.test': ['127.0.0.1'],},},});Use dns.doh to resolve request hostnames through DNS-over-HTTPS:
awaitfetch('https://example.com',{dns: {doh: 'https://cloudflare-dns.com/dns-query',},});Use dns.dot to resolve request hostnames through DNS-over-TLS:
awaitfetch('https://example.com',{dns: {dot: 'tls://one.one.one.one',},});dns.doh and dns.dot are mutually exclusive. When either encrypted DNS mode is set,
dns.servers are used only to resolve the encrypted DNS endpoint hostname. If dns.servers
are omitted, the endpoint is resolved with the system DNS settings.
awaitfetch('https://example.com',{dns: {doh: 'https://cloudflare-dns.com/dns-query',servers: ['9.9.9.9'],},});