The UUID Gotcha That Burned Me: Why UUID4 Isn't Always Random Enough

Three months ago, I was investigating a bizarre bug in our distributed job processing system. Jobs were occasionally getting assigned to the wrong workers, and the pattern made no sense. The job IDs were UUIDs, supposedly unique and random. The worker assignment was based on consistent hashing of those IDs. Everything should have been evenly distributed.

Except it wasn't.

After days of head-scratching, I discovered something that made me question everything I thought I knew about UUIDs: our "random" UUID4s weren't random enough. And the culprit wasn't our code—it was the virtual machine's entropy pool.

The Entropy Illusion

Most developers treat UUIDs as a black box. You call uuid.v4(), you get a unique identifier, job done. But under the hood, UUID4 generation depends on cryptographically secure random number generation, which requires entropy—actual randomness from the operating system.

Here's the problem: entropy is a finite resource.

// This looks innocent enough
const jobId = uuid.v4();
console.log(jobId); // "f47ac10b-58cc-4372-a567-0e02b2c3d479"

But what happens when you generate thousands of UUIDs per second? Or when you're running in a freshly booted VM with low entropy? The answer surprised me.

The Entropy Starvation Scenario

I set up a test to reproduce the issue. Here's what I found when generating UUIDs rapidly on a entropy-starved system:

// Generate 10,000 UUIDs rapidly
const uuids = [];
const start = Date.now();

for (let i = 0; i < 10000; i++) {
  uuids.push(uuid.v4());
}

console.log(`Generated in ${Date.now() - start}ms`);

// Check for suspicious patterns
const firstBytes = uuids.map(id => id.substring(0, 8));
const unique = new Set(firstBytes);
console.log(`Unique first 8 chars: ${unique.size} / ${uuids.length}`);

On a healthy system: 500ms, 9,998 unique prefixes (pretty good). On an entropy-starved VM: 45 seconds, 7,234 unique prefixes (yikes).

The entropy-starved system was not only slower—it was generating UUIDs with detectable patterns. That's how our job distribution got skewed.

The Docker Multiplier Effect

This problem gets worse in containerized environments. Docker containers share the host's entropy pool, so spinning up multiple containers that all generate UUIDs simultaneously can quickly exhaust available entropy.

I created a simple test:

# Run 10 containers simultaneously generating UUIDs
for i in {1..10}; do
  docker run --rm -d node:alpine -e "
    const uuid = require('uuid');
    console.time('uuid-generation');
    for(let i = 0; i < 1000; i++) {
      uuid.v4();
    }
    console.timeEnd('uuid-generation');
  "
done

First container: ~50ms Tenth container: ~8 seconds

The last containers were essentially starved of entropy, leading to predictable delays and potentially weaker randomness.

Detecting Entropy Starvation

Here's how to check if your system is entropy-starved:

# Check available entropy (Linux)
cat /proc/sys/kernel/random/entropy_avail

# Values below 1000 are concerning
# Values below 100 are critical

You can also detect it programmatically by measuring UUID generation speed:

function checkEntropyHealth() {
  const iterations = 1000;
  const start = process.hrtime.bigint();
  
  for (let i = 0; i < iterations; i++) {
    crypto.randomBytes(16);
  }
  
  const end = process.hrtime.bigint();
  const microseconds = Number(end - start) / 1000;
  const avgMicroseconds = microseconds / iterations;
  
  if (avgMicroseconds > 1000) {
    console.warn('Entropy starvation detected!');
  }
  
  return avgMicroseconds;
}

Smart Alternatives for High-Throughput Scenarios

When you need thousands of unique IDs per second, UUID4 might not be the right choice. Here are some alternatives I've used:

Snowflake IDs

Twitter's Snowflake algorithm generates 64-bit IDs that are unique, roughly sortable, and don't require cryptographic randomness:

class SnowflakeId {
  constructor(machineId = 1) {
    this.machineId = machineId & 0x3FF; // 10 bits
    this.sequence = 0;
    this.lastTimestamp = 0;
    this.epoch = 1640995200000; // 2022-01-01
  }
  
  generate() {
    let timestamp = Date.now();
    
    if (timestamp < this.lastTimestamp) {
      throw new Error('Clock moved backwards');
    }
    
    if (timestamp === this.lastTimestamp) {
      this.sequence = (this.sequence + 1) & 0xFFF; // 12 bits
      if (this.sequence === 0) {
        // Wait for next millisecond
        while (timestamp <= this.lastTimestamp) {
          timestamp = Date.now();
        }
      }
    } else {
      this.sequence = 0;
    }
    
    this.lastTimestamp = timestamp;
    
    return ((timestamp - this.epoch) << 22) | 
           (this.machineId << 12) | 
           this.sequence;
  }
}

Snowflake IDs are perfect for distributed systems and can generate millions of unique IDs per second without entropy concerns.

ULID (Universally Unique Lexicographically Sortable Identifier)

ULIDs combine the benefits of UUIDs with lexicographical sorting:

const { ulid } = require('ulid');

// Generate a ULID
const id = ulid(); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"

// ULIDs are sortable by creation time
const ids = [ulid(), ulid(), ulid()];
console.log(ids.sort()); // Always in chronological order

ULIDs use less entropy than UUID4 and are more database-friendly due to their sortable nature.

Hybrid Approach: Cached Entropy

For cases where you need cryptographic randomness but want to avoid entropy starvation:

class EntropyPool {
  constructor(poolSize = 1024) {
    this.pool = crypto.randomBytes(poolSize);
    this.position = 0;
  }
  
  getBytes(length) {
    if (this.position + length > this.pool.length) {
      // Refresh pool
      this.pool = crypto.randomBytes(this.pool.length);
      this.position = 0;
    }
    
    const bytes = this.pool.slice(this.position, this.position + length);
    this.position += length;
    return bytes;
  }
}

const entropyPool = new EntropyPool();

function fastUUID() {
  const bytes = entropyPool.getBytes(16);
  
  // Set version (4) and variant bits
  bytes[6] = (bytes[6] & 0x0F) | 0x40;
  bytes[8] = (bytes[8] & 0x3F) | 0x80;
  
  // Convert to UUID string format
  const hex = bytes.toString('hex');
  return [
    hex.slice(0, 8),
    hex.slice(8, 12),
    hex.slice(12, 16),
    hex.slice(16, 20),
    hex.slice(20, 32)
  ].join('-');
}

This approach amortizes the entropy cost across multiple UUID generations.

Production Lessons

Here's what I learned from this experience:

Monitor entropy levels in production. Low entropy can cause mysterious performance issues and security vulnerabilities.

Consider your ID generation patterns. Are you generating thousands of IDs per second? During container startup? In batch jobs? These patterns matter.

Test with realistic loads. Your local machine with a GUI and background processes has different entropy characteristics than a headless server.

Don't assume UUIDs are "free." Like any system resource, entropy has limits.

The Right Tool for the Job

UUIDs are great for many use cases, but they're not always the answer:

  • Low-frequency, high-security needs: UUID4 is perfect
  • High-throughput distributed systems: Consider Snowflake IDs
  • Database-friendly sortable IDs: Try ULIDs
  • Legacy system integration: Sometimes sequential IDs are unavoidable

The key is understanding the trade-offs and choosing the right identifier strategy for your specific use case.


Building fast, secure tools is all about making informed trade-offs. That's the philosophy behind ToolShelf—giving developers the utilities they need without the performance penalties they don't.

Stay safe & happy coding,
— ToolShelf Team