DEV Community

Cover image for CycleTLS v2: Why We Ditched Axios for TLS Fingerprint-Safe HTTP
HelperX
HelperX

Posted on • Originally published at helperx.app

CycleTLS v2: Why We Ditched Axios for TLS Fingerprint-Safe HTTP

When you're automating requests against platforms that actively detect bots, your HTTP library is a liability. We learned this the hard way — Axios was getting our requests fingerprinted and blocked within hours. Here's why we migrated to CycleTLS v2 and what it took to make it production-ready.

The fingerprinting problem

Every HTTP client has a TLS fingerprint. When your browser connects to a server over HTTPS, the TLS handshake reveals:

  • Supported cipher suites (and their order)
  • TLS extensions
  • Elliptic curve preferences
  • ALPN protocols
  • Signature algorithms

This combination creates a fingerprint — called a JA3 hash — that uniquely identifies the client. Chrome has one fingerprint. Firefox has another. Node.js https module has yet another.

And every bot-detection service knows what Node.js looks like.

# Node.js 20 JA3 hash (via Axios/https)
769,4866-4867-4865-49196-49200-159-52393-52392-52394...

# Chrome 124 JA3 hash
769,4865-4866-4867-49195-49199-49196-49200-52393-52392...
Enter fullscreen mode Exit fullscreen mode

The cipher suite order is different. The extensions list is different. It's trivial for a server to distinguish Node.js from a real browser — and block accordingly.

What CycleTLS does differently

CycleTLS spawns a Go process that handles TLS handshakes using Go's crypto/tls package, configured to mimic real browser fingerprints. The Node.js side communicates with this Go process via WebSocket.

┌──────────────┐     WebSocket     ┌──────────────┐
│   Node.js    │ ◄──────────────► │   Go binary  │
│  (your app)  │                   │  (TLS proxy) │
└──────────────┘                   └──────────────┘
                                          │
                                     TLS handshake
                                   (Chrome fingerprint)
                                          │
                                   ┌──────────────┐
                                   │  Target API  │
                                   └──────────────┘
Enter fullscreen mode Exit fullscreen mode

The target server sees a Chrome-like TLS handshake. Your application logic stays in Node.js. The fingerprint problem is solved at the transport layer.

Migrating from v1 to v2

CycleTLS v2 introduced breaking changes that improved reliability but required significant refactoring. Here's what changed and how we handled it.

Connection lifecycle

In v1, you created a CycleTLS instance and it managed connections internally:

// v1 — implicit lifecycle
import CycleTLS from 'cycletls';

const cycleTLS = new CycleTLS();
const response = await cycleTLS.get('https://api.example.com/data', {
  ja3: '771,4865-4866-4867...',
  userAgent: 'Mozilla/5.0...'
});
Enter fullscreen mode Exit fullscreen mode

In v2, connection management is explicit:

// v2 — explicit lifecycle
import { CycleTLS } from 'cycletls';

const cycleTLS = new CycleTLS();
await cycleTLS.start();

const response = await cycleTLS.get('https://api.example.com/data', {
  ja3: '771,4865-4866-4867...',
  userAgent: 'Mozilla/5.0...'
});

await cycleTLS.exit();
Enter fullscreen mode Exit fullscreen mode

The start() and exit() methods give you control over the Go subprocess lifecycle. In v1, the subprocess could linger if your Node.js process crashed. In v2, you manage it explicitly.

Error handling

V1 swallowed many errors silently. V2 throws properly:

try {
  const response = await cycleTLS.get(url, options);
  if (response.status !== 200) {
    // v2 returns status codes consistently
    handleApiError(response.status, response.body);
  }
} catch (err) {
  if (err.message.includes('connection refused')) {
    // Go subprocess died — need to restart
    await cycleTLS.exit().catch(() => {});
    await cycleTLS.start();
    // retry...
  }
}
Enter fullscreen mode Exit fullscreen mode

Per-account circuit breaker

The biggest addition in our v2 migration: a circuit breaker per account slot. If a slot's requests start failing consistently, the circuit opens and stops sending requests — preventing cascade failures.

class SlotCircuitBreaker {
  constructor(slotId, { threshold = 5, resetTimeout = 60000 } = {}) {
    this.slotId = slotId;
    this.failures = 0;
    this.threshold = threshold;
    this.resetTimeout = resetTimeout;
    this.state = 'closed'; // closed | open | half-open
    this.lastFailure = null;
  }

  async execute(fn) {
    if (this.state === 'open') {
      if (Date.now() - this.lastFailure > this.resetTimeout) {
        this.state = 'half-open';
      } else {
        throw new Error(`Circuit open for slot ${this.slotId}`);
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  onSuccess() {
    this.failures = 0;
    this.state = 'closed';
  }

  onFailure() {
    this.failures++;
    this.lastFailure = Date.now();
    if (this.failures >= this.threshold) {
      this.state = 'open';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Each slot gets its own circuit breaker. If slot A's proxy goes down, slot B continues operating. The circuit breaker prevents slot A from hammering a dead proxy and burning through rate limits.

JA3 fingerprint management

We maintain a rotation of JA3 fingerprints matching current browser versions:

const JA3_PROFILES = {
  chrome_124: {
    ja3: '771,4865-4866-4867-49195-49199-49196-49200-52393-52392-52394-49327-49325-49315-49311-49245-49249-49239-49235-158-162-49267-49271-107-103-49268-49272-57-51-159-163-49312-49316-49246-49250-49240-49236-52393,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513-21,29-23-24,0',
    userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...'
  },
  chrome_125: { /* ... */ },
  firefox_126: { /* ... */ },
};

function getRandomProfile() {
  const keys = Object.keys(JA3_PROFILES);
  return JA3_PROFILES[keys[Math.floor(Math.random() * keys.length)]];
}
Enter fullscreen mode Exit fullscreen mode

We update these profiles monthly when major browser versions release. Stale fingerprints get detected — a Chrome 110 fingerprint in 2026 is as suspicious as a Node.js fingerprint.

Performance comparison

After the migration, we benchmarked both approaches:

Metric Axios + Node TLS CycleTLS v2
Request latency (avg) 180ms 220ms
Block rate (24h) 34% 0.8%
Successful sessions (7d) 62% 97%
Memory overhead 45MB 85MB (Go process)
Crash recovery Manual restart Auto-restart via circuit breaker

CycleTLS adds ~40ms latency and ~40MB memory for the Go subprocess. In return, our block rate dropped from 34% to under 1%. The tradeoff is obvious.

Lessons learned

1. Transport-layer fingerprinting is the first detection layer. You can have perfect request timing, realistic headers, and residential IPs — if your TLS fingerprint says "Node.js," you're blocked before the request is parsed.

2. Library upgrades in critical paths need circuit breakers. We deployed v2 without a circuit breaker initially. The first time the Go subprocess crashed under load, all slots went down simultaneously. Circuit breakers per slot fixed this.

3. JA3 profiles have a shelf life. We got lazy about updating profiles for two months. Block rates crept up from 0.8% to 6% before we noticed. Monthly updates are non-negotiable.

4. Explicit lifecycle management is worth the boilerplate. V1's implicit management was convenient until it wasn't. V2's explicit start()/exit() made debugging subprocess issues trivial.

5. Go + Node.js via WebSocket is a viable production pattern. We were skeptical about the multi-process architecture. After 6 months in production, the WebSocket bridge has been rock-solid. The Go binary handles what Go does best (TLS), and Node.js handles what it does best (async I/O and application logic).


HelperX uses CycleTLS v2 with per-account circuit breakers for fingerprint-safe automation. Try it free for 30 days.

Top comments (0)