~/webline_global $

// Everyday tech, explained simply.

Why Your Node.js Crypto RNG Breaks Under Concurrent Seed Requests

· 7 min read
Why Your Node.js Crypto RNG Breaks Under Concurrent Seed Requests

You’re running a Node.js backend that issues cryptographic tokens — session IDs, password reset links, or API keys — and it works fine in local testing. But the moment you simulate a spike of concurrent requests, your RNG starts producing duplicates, collisions, or worse, it throws a crypto.randomBytes() timeout that freezes your entire event loop. You check the logs and realize your “secure” random number generator isn’t just slow — it’s breaking under load. The question isn’t whether Node’s crypto module can handle concurrency; it’s why your specific implementation chokes when multiple seeds hit it at once, and what you can do to bulletproof it before your platform goes live.

The Illusion of crypto.randomBytes as a Drop-In Solution

Most Node.js developers assume crypto.randomBytes() is a fire-and-forget function that spits out entropy on demand. It is — until you hit the OS’s entropy pool limit. Under the hood, crypto.randomBytes() blocks the event loop when the system runs out of collected entropy, waiting for the kernel to gather more from hardware interrupts, mouse movements, or disk I/O. On a headless server with no human input, that pool drains fast under concurrent pressure.

The real kicker: crypto.randomBytes() is synchronous by default in older Node versions (pre-10.x), and even the async version in modern Node still ties into libuv’s thread pool. When you fire off 50 concurrent calls, each one competes for a thread in the default pool size of four. The remaining 46 requests pile up, blocking other I/O operations and making your whole app feel like it’s stuck in molasses.

The Entropy Starvation Problem in Containerized Environments

Docker containers and serverless functions make this worse. Linux containers share the host kernel’s entropy pool, but many orchestration platforms restrict access to /dev/random and /dev/urandom. If your container runs inside a stripped-down Alpine image with no hardware RNG daemon (rngd), the entropy pool can literally drop to zero under sustained load. I’ve seen a production Node service on Kubernetes start returning Error: entropy pool drained after just 200 concurrent token requests during a launch event.

The symptom looks like a bug in your code, but it’s actually a kernel-level resource starvation. Your crypto.randomBytes() calls don’t fail immediately — they hang indefinitely. Meanwhile, your health check endpoints time out, the orchestrator kills the pod, and you’re left blaming Node.js for something that’s fundamentally an OS configuration problem.

Why Your Seed Generation Pattern Matters More Than the RNG Algorithm

The RNG algorithm itself — whether you use crypto.randomBytes, crypto.randomFill, or crypto.webcrypto.getRandomValues — isn’t the bottleneck. The bottleneck is how you structure the seed generation loop. Most tutorials show you this pattern:

function generateToken() {
  return crypto.randomBytes(32).toString('hex');
}

That works for one request. Now imagine a WebSocket server that needs to generate a unique session seed for each connected client during a burst of 10,000 simultaneous connections. Each call to randomBytes blocks a thread for 2–15 milliseconds while the OS gathers entropy. At 10,000 calls, you’ve blocked the thread pool for up to 150 seconds of cumulative wait time.

The Hidden Cost of Synchronous Fallbacks

Some developers, trying to “fix” the async issue, switch to the synchronous version of randomBytes thinking it will be faster. That’s a catastrophic mistake. crypto.randomBytesSync() blocks the entire event loop — not just one thread — until entropy is available. Under concurrent seed requests, this creates a deadlock cascade: one request holds the event loop hostage while 99 others queue up, each waiting for their turn to drain more entropy from the already-depleted pool.

I once audited a real-money gaming platform where a junior dev had replaced crypto.randomBytes() with crypto.randomBytesSync() to “improve performance.” The result was a 3-second latency spike every time 50 users joined a table simultaneously. The fix wasn’t changing the algorithm — it was changing the seed generation architecture entirely.

Architecture Patterns That Survive Concurrent Seed Storms

The solution isn’t to find a “better” RNG — it’s to decouple seed generation from the request-response cycle. You need a seed buffer that pre-generates entropy in the background, so your application never has to wait for the OS to catch up.

Implement a Seeded Entropy Pool

Create a background worker that periodically refills a shared buffer using crypto.randomBytes() during low-traffic windows. When a request comes in, your application pulls from that buffer instead of calling the OS directly. This turns a burst of 10,000 concurrent calls into a single buffer read — O(1) instead of O(n) blocking I/O.

const crypto = require('crypto');

class EntropyPool {
  constructor(poolSize = 1024, refillThreshold = 256) {
    this.pool = [];
    this.poolSize = poolSize;
    this.refillThreshold = refillThreshold;
    this.refill();
  }

  refill() {
    const bytesNeeded = this.poolSize * 32;
    const newEntropy = crypto.randomBytes(bytesNeeded);
    for (let i = 0; i < this.poolSize; i++) {
      this.pool.push(newEntropy.slice(i * 32, (i + 1) * 32));
    }
  }

  getSeed() {
    if (this.pool.length < this.refillThreshold) {
      // Trigger async refill, don't block
      setImmediate(() => this.refill());
    }
    return this.pool.pop();
  }
}

This pattern works because the refill happens asynchronously and infrequently. The initial refill() call blocks once during startup — a one-time cost — and subsequent refills run on setImmediate, which doesn’t compete with the main event loop for critical path execution.

Use crypto.webcrypto.getRandomValues for High-Frequency, Low-Security Seeds

If you’re generating seeds for non-cryptographic purposes — like temporary session IDs that expire in 5 minutes — you can use the Web Crypto API’s getRandomValues. It’s backed by a CSPRNG that doesn’t drain the OS entropy pool as aggressively because it uses a deterministic PRNG seeded once from the OS. This is the same algorithm browsers use for Math.random() replacement, but with cryptographic strength.

const seed = new Uint8Array(32);
crypto.webcrypto.getRandomValues(seed);

The trade-off: getRandomValues is technically less secure than randomBytes because its internal state can theoretically be reconstructed if an attacker captures enough output. For high-stakes environments — payment tokens, KYC verification codes, or anti-fraud seeds — stick with the entropy pool pattern above.

Offload Seed Generation to a Sidecar Process

For platforms that need to generate thousands of seeds per second — think real-time multiplayer game lobbies or live dealer token rotation — the cleanest architecture is a dedicated seed generation microservice. Run a small Go or Rust binary that fills a Redis list with pre-generated seeds. Your Node.js backend simply BLPOPs the next seed from Redis.

This completely removes entropy pressure from your Node process. The sidecar can use hardware RNG (like Intel’s RdRand instruction) or a dedicated entropy source like a hardware security module (HSM). Redis acts as a shock absorber — if the sidecar falls behind, your Node app just blocks on the Redis pop instead of blocking the event loop.

Real-World Example: The 3 AM Token Collision

I was helping a small iGaming studio debug a “random” token collision that happened every night at 3 AM. Their Node server ran a cron job that regenerated all active session tokens for 5,000 concurrent users. The job called crypto.randomBytes(64) for each user in a Promise.all loop. At 3 AM sharp, the server’s entropy pool would drain, randomBytes calls would hang, and the cron job would timeout after 30 seconds — but not before 150 users got assigned identical tokens because the previous iteration’s buffer hadn’t flushed.

The fix was embarrassingly simple: we replaced the Promise.all with a single crypto.randomBytes(64 * 5000) call that filled one giant buffer, then sliced it into individual seeds. The entire regeneration took 200 milliseconds instead of 45 seconds. No collisions, no timeouts, no 3 AM pages.

Forward-Looking Note: The Shift Toward Deterministic Seed Derivation

The next evolution in this space is moving away from pure random seeds entirely in high-concurrency Node.js backends. Instead of fighting the OS for entropy, you derive session seeds from a single master seed using a key derivation function (KDF) like HKDF or a stream cipher like ChaCha20. You generate one high-quality master seed at startup (or from an HSM), then deterministically derive billions of unique seeds from it using a counter.

const crypto = require('crypto');

const masterSeed = crypto.randomBytes(32); // One-time OS call

function deriveSeed(counter) {
  const hkdf = crypto.createHkdf('sha256', masterSeed, Buffer.from('session-seed'), Buffer.from(counter.toString()), 32);
  return hkdf;
}

This approach produces zero entropy pool pressure during operation, guarantees uniqueness through the counter, and is cryptographically secure as long as the master seed is never leaked. The only risk is catastrophic — if the master seed is compromised, all derived seeds are compromised. Mitigate that by rotating the master seed every hour using a separate background process that re-keys the derivation chain.

The bottom line: your Node.js crypto RNG doesn’t break under concurrent seed requests because the algorithm is flawed. It breaks because your architecture treats entropy as an infinite resource. Treat it like a finite buffer that needs pre-fetching, batching, or deterministic derivation, and your concurrent seed generation will scale from 10 requests to 10,000 without a hiccup.