node-rs icon indicating copy to clipboard operation
node-rs copied to clipboard

Comprehensive Argon2 Benchmark Results (feel free to close, just sharing! 🎉 )

Open titanism opened this issue 4 months ago • 9 comments

EDIT: See comment at https://github.com/napi-rs/node-rs/issues/1041#issuecomment-3572282535 for more accurate benchmarks

Comprehensive Argon2 Benchmark Results

Hi folks 👋 Team here from @forwardemail (https://forwardemail.net - an open-source privacy-focused email service).

I've been working on optimizing some things and switching from PBKDF2 to Argon2. As such, I was in search of the best solution, so I'm sharing some findings and benchmarks...

This benchmark compares the performance of three Argon2 implementations for Node.js:

  • @node-rs/argon2 - Rust-based native binding
  • argon2 - C++ native binding (node-argon2)
  • @noble/hashes - Pure JavaScript/TypeScript implementation

Test Environment

  • CPU Cores: 6 (used for parallelism setting)
  • Benchmark Tool: tinybench v3.1.1
  • Test Password: $v=19$m=4096,t=3,p=1$fyLYvmzgpBjDTP6QSypj3g$pb1Q3Urv1amxuFft0rGwKfEuZPhURRDV7TJqcBnwlGo
  • Algorithm: Argon2id (recommended default)
  • Parameters:
    • Memory cost (m): 4096 KB
    • Time cost (t): 3 iterations
    • Parallelism (p): 6 threads (matching CPU cores)

Benchmark Results

Task Name Latency Avg (ms) Latency Med (ms) Throughput Avg (ops/s) Throughput Med (ops/s) Samples
@node-rs/argon2 hash 27.40 ± 3.20% 26.93 ± 1.36 37 ± 2.81% 37 ± 2 64
argon2 (C++ binding) hash 59.34 ± 1.62% 58.39 ± 1.77 17 ± 1.53% 17 ± 1 64
@noble/hashes argon2id hash 195.70 ± 0.73% 193.99 ± 2.08 5 ± 0.68% 5 ± 0 64
@node-rs/argon2 verify 34.43 ± 3.92% 33.19 ± 3.66 30 ± 3.54% 30 ± 4 64
argon2 (C++ binding) verify 59.84 ± 1.39% 58.80 ± 2.24 17 ± 1.35% 17 ± 1 64

Note: @noble/hashes does not support password verification against existing hashes in the same format, so verify benchmarks are not available for this implementation.

Performance Analysis

Hashing Performance

Winner: @node-rs/argon2 🏆

Implementation Avg Latency Performance vs @node-rs/argon2
@node-rs/argon2 27.40 ms Baseline (fastest)
argon2 (C++ binding) 59.34 ms 2.17x slower
@noble/hashes 195.70 ms 7.14x slower

The Rust-based @node-rs/argon2 implementation demonstrates exceptional hashing performance:

  • 2.17x faster than the established C++ binding (argon2/node-argon2)
  • 7.14x faster than the pure JavaScript implementation (@noble/hashes)
  • Highest throughput at 37 operations per second

Verification Performance

Winner: @node-rs/argon2 🏆

Implementation Avg Latency Performance vs @node-rs/argon2
@node-rs/argon2 34.43 ms Baseline (fastest)
argon2 (C++ binding) 59.84 ms 1.74x slower

The Rust implementation also leads in verification performance:

  • 1.74x faster than the C++ binding
  • Consistent performance advantage across both hashing and verification operations

Key Findings

1. @node-rs/argon2 (Rust) - Clear Winner

Advantages:

  • Fastest for both hashing and verification operations
  • 2.17x faster hashing compared to the popular C++ binding
  • 1.74x faster verification compared to the C++ binding
  • Excellent consistency (low variance in measurements)
  • Memory safety guarantees from Rust
  • Cross-platform compilation benefits

Use Cases:

  • Production applications requiring maximum performance
  • High-throughput authentication systems
  • User registration flows
  • Password change operations
  • Any scenario where performance is critical

2. argon2 (C++ binding) - Solid Second Place

Advantages:

  • Well-established and widely used
  • Still provides good performance
  • Mature ecosystem and documentation

Disadvantages:

  • 2.17x slower than @node-rs/argon2 for hashing
  • 1.74x slower than @node-rs/argon2 for verification

Use Cases:

  • Legacy applications already using this library
  • When maximum compatibility is needed
  • When the performance difference is not critical

3. @noble/hashes (Pure JS) - Portable but Slow

Advantages:

  • No native compilation required
  • Works in any JavaScript environment
  • Audited and minimal implementation
  • Good for environments where native bindings can't be used

Disadvantages:

  • 7.14x slower than @node-rs/argon2 for hashing
  • Significantly lower throughput (5 ops/s vs 37 ops/s)
  • Not suitable for high-performance scenarios
  • Does not provide compatible password verification

Use Cases:

  • Browser-based password hashing
  • Environments without native module support
  • Development/testing when native modules are problematic
  • Low-traffic applications where performance is not critical

Recommendations

For Production Use

Use @node-rs/argon2 for the best performance. It provides:

  • The fastest hashing and verification
  • Modern Rust implementation with safety guarantees
  • Excellent performance characteristics
  • Active maintenance

Migration Path

If you're currently using argon2 (C++ binding), migrating to @node-rs/argon2 will provide:

  • 2.17x faster user registration/password changes
  • 1.74x faster login authentication
  • Reduced server load and improved user experience
  • Compatible hash format (can verify existing hashes)

When to Use Alternatives

  • argon2 (C++ binding): If you have a stable production system and the performance difference doesn't justify migration effort
  • @noble/hashes: Only for browser-based hashing or environments where native modules cannot be used

Conclusion

The @node-rs/argon2 (Rust implementation) is the clear performance leader, outperforming both the established C++ binding and the pure JavaScript implementation by significant margins. For any Node.js application where password hashing performance matters, @node-rs/argon2 is the recommended choice.

The performance advantage is particularly notable in high-throughput scenarios such as:

  • Large-scale user authentication systems
  • API services with frequent password operations
  • Applications with strict latency requirements

Given that password hashing is a CPU-intensive operation that directly impacts user experience (registration and login times), the 2x+ performance improvement offered by @node-rs/argon2 can translate to meaningful real-world benefits.


import { cpus } from 'node:os'

import nodeArgon2 from 'argon2'
import { Bench } from 'tinybench'
import { hash, verify, Algorithm } from '@node-rs/argon2'
import { argon2id as nobleArgon2id } from '@noble/hashes/argon2.js'
// import { hash as argon2idHash, verify as argon2idVerify } from 'argon2id' // WASM loading issues

const PASSWORD = '$v=19$m=4096,t=3,p=1$fyLYvmzgpBjDTP6QSypj3g$pb1Q3Urv1amxuFft0rGwKfEuZPhURRDV7TJqcBnwlGo'
const CORES = cpus().length

console.log(`Running benchmarks with ${CORES} CPU cores\n`)

// Generate hashes for verification tests
const nodeRsHashed = await hash(PASSWORD, {
  algorithm: Algorithm.Argon2id,
  parallelism: CORES,
})

const nodeArgon2Hashed = await nodeArgon2.hash(PASSWORD, { 
  type: nodeArgon2.argon2id, 
  parallelism: CORES 
})

// const argon2idHashed = await argon2idHash(PASSWORD)

const bench = new Bench()

// Hash benchmarks
bench
  .add('@node-rs/argon2 hash', async () => {
    await hash(PASSWORD, {
      algorithm: Algorithm.Argon2id,
      parallelism: CORES,
    })
  })
  .add('argon2 (C++ binding) hash', async () => {
    await nodeArgon2.hash(PASSWORD, { type: nodeArgon2.argon2id, parallelism: CORES })
  })
  .add('@noble/hashes argon2id hash', () => {
    // @noble/hashes with similar parameters: m=4096, t=3, p=CORES
    nobleArgon2id(PASSWORD, 'somesalt1234567890123456789012', { t: 3, m: 4096, p: CORES })
  })
  // .add('argon2id (pure JS) hash', async () => {
  //   await argon2idHash(PASSWORD)
  // })

// Verify benchmarks
bench
  .add('@node-rs/argon2 verify', async () => {
    const result = await verify(nodeRsHashed, PASSWORD)
    console.assert(result)
  })
  .add('argon2 (C++ binding) verify', async () => {
    const result = await nodeArgon2.verify(nodeArgon2Hashed, PASSWORD)
    console.assert(result)
  })
  // .add('argon2id (pure JS) verify', async () => {
  //   const result = await argon2idVerify(argon2idHashed, PASSWORD)
  //   console.assert(result)
  // })

await bench.run()

console.log('\n=== BENCHMARK RESULTS ===\n')
console.table(bench.table())

// Calculate and display performance comparisons
console.log('\n=== PERFORMANCE COMPARISON ===\n')

const tasks = bench.tasks
const nodeRsHashTask = tasks.find(t => t.name === '@node-rs/argon2 hash')
const argon2HashTask = tasks.find(t => t.name === 'argon2 (C++ binding) hash')
const nobleHashTask = tasks.find(t => t.name === '@noble/hashes argon2id hash')
// const argon2idHashTask = tasks.find(t => t.name === 'argon2id (pure JS) hash')

const nodeRsVerifyTask = tasks.find(t => t.name === '@node-rs/argon2 verify')
const argon2VerifyTask = tasks.find(t => t.name === 'argon2 (C++ binding) verify')
// const argon2idVerifyTask = tasks.find(t => t.name === 'argon2id (pure JS) verify')

console.log('Hash Performance (lower latency is better):')
console.log(`  @node-rs/argon2:        ${(nodeRsHashTask.result.mean * 1000).toFixed(2)} ms`)
console.log(`  argon2 (C++ binding):   ${(argon2HashTask.result.mean * 1000).toFixed(2)} ms`)
console.log(`  @noble/hashes:          ${(nobleHashTask.result.mean * 1000).toFixed(2)} ms`)
// console.log(`  argon2id (pure JS):     ${(argon2idHashTask.result.mean * 1000).toFixed(2)} ms`)

console.log('\nVerify Performance (lower latency is better):')
console.log(`  @node-rs/argon2:        ${(nodeRsVerifyTask.result.mean * 1000).toFixed(2)} ms`)
console.log(`  argon2 (C++ binding):   ${(argon2VerifyTask.result.mean * 1000).toFixed(2)} ms`)
// console.log(`  argon2id (pure JS):     ${(argon2idVerifyTask.result.mean * 1000).toFixed(2)} ms`)

console.log('\nSpeed Comparison (relative to @node-rs/argon2):')
console.log('Hash:')
console.log(`  argon2 (C++ binding):   ${(argon2HashTask.result.mean / nodeRsHashTask.result.mean).toFixed(2)}x ${nodeRsHashTask.result.mean < argon2HashTask.result.mean ? 'faster' : 'slower'}`)
console.log(`  @noble/hashes:          ${(nobleHashTask.result.mean / nodeRsHashTask.result.mean).toFixed(2)}x ${nodeRsHashTask.result.mean < nobleHashTask.result.mean ? 'faster' : 'slower'}`)
// console.log(`  argon2id (pure JS):     ${(argon2idHashTask.result.mean / nodeRsHashTask.result.mean).toFixed(2)}x ${nodeRsHashTask.result.mean < argon2idHashTask.result.mean ? 'faster' : 'slower'}`)

console.log('\nVerify:')
console.log(`  argon2 (C++ binding):   ${(argon2VerifyTask.result.mean / nodeRsVerifyTask.result.mean).toFixed(2)}x ${nodeRsVerifyTask.result.mean < argon2VerifyTask.result.mean ? 'faster' : 'slower'}`)
// console.log(`  argon2id (pure JS):     ${(nodeRsVerifyTask.result.mean / argon2idVerifyTask.result.mean).toFixed(2)}x ${nodeRsVerifyTask.result.mean < argon2idVerifyTask.result.mean ? 'faster' : 'slower'}`)

titanism avatar Nov 21 '25 00:11 titanism

Argon2 vs PBKDF2 - Quick Results Summary

Performance Comparison Across Node.js Versions

Hash Performance (milliseconds - lower is better)

Node.js Version Argon2 PBKDF2 Winner Speed Advantage
v18.20.8 124.10 ms 268.76 ms Argon2 2.17x faster
v20.19.5 124.64 ms 268.27 ms Argon2 2.15x faster
v22.21.1 127.69 ms 192.01 ms Argon2 1.50x faster
v24.11.1 134.63 ms 199.48 ms Argon2 1.48x faster

Verify Performance (milliseconds - lower is better)

Node.js Version Argon2 PBKDF2 Winner Speed Advantage
v18.20.8 112.99 ms 267.02 ms Argon2 2.36x faster
v20.19.5 115.53 ms 267.96 ms Argon2 2.32x faster
v22.21.1 123.15 ms 193.72 ms Argon2 1.57x faster
v24.11.1 115.69 ms 215.31 ms Argon2 1.86x faster

Configurations Used

PBKDF2

{
  digestAlgorithm: 'sha256',
  salt: 32 bytes,
  iterations: 25000,
  keylen: 512 bytes
}

Argon2

{
  algorithm: 'Argon2id',
  memoryCost: 65536 KB (64 MiB),
  timeCost: 2,
  parallelism: 1,
  outputLen: 32 bytes
}

Bottom Line

Argon2 is faster across all Node.js versions (1.48x - 2.36x)

Argon2 is more secure (memory-hard, GPU-resistant)

Argon2 is more consistent (stable performance across versions)

Recommended choice: Use @node-rs/argon2 for new projects

Visual Performance Comparison

Hash Performance (Node v18):
Argon2:  ████████████ 124ms
PBKDF2:  ██████████████████████████ 269ms

Hash Performance (Node v24):
Argon2:  █████████████ 135ms
PBKDF2:  ███████████████████ 199ms

Verify Performance (Node v18):
Argon2:  ███████████ 113ms
PBKDF2:  ██████████████████████████ 267ms

Verify Performance (Node v24):
Argon2:  ███████████ 116ms
PBKDF2:  █████████████████████ 215ms

Recommendation

Use @node-rs/argon2 for:

  • New applications
  • High-security requirements
  • High-traffic authentication systems
  • Better performance and user experience

Consider migrating from PBKDF2 if:

  • You're on Node.js v18 or v20 (2x+ performance gain)
  • Security is a priority
  • You want to reduce server load
  • You want faster authentication times

titanism avatar Nov 21 '25 01:11 titanism

Why are you comparing slow pbkdf2 params with fast argon2? Slowness of KDFs is a feature, not a bug. Time cost (t) should be increased to 4 or 5.

The other chosen parameters are also not optimal:

Memory cost (m): 4096 KB, but should be 64-256MB to prevent brute forcing by GPU

Parallelism (p): 6 threads, but should be 1. Parallelism is not available in all environments (js without workers) and is generally less useful compared to ram cost.

paulmillr avatar Nov 21 '25 19:11 paulmillr

If you read my previous issue you will see that @node-rs/argon2 defaults are much lower (it's even wrong compared to the README). You must compare the libraries using the same input parameters, otherwise you're just measuring default safety.

If you add m=4096 and t=3 to both library calls:

=== PERFORMANCE COMPARISON ===

Hash Performance (lower latency is better):
  @node-rs/argon2:        2778.06 ms
  argon2 (C++ binding):   1683.28 ms
  @noble/hashes:          90833.76 ms

Verify Performance (lower latency is better):
  @node-rs/argon2:        2790.08 ms
  argon2 (C++ binding):   1732.99 ms

Speed Comparison (relative to @node-rs/argon2):
Hash:
  argon2 (C++ binding):   0.61x slower
  @noble/hashes:          32.70x faster

Verify:
  argon2 (C++ binding):   0.62x slower

ranisalt avatar Nov 24 '25 19:11 ranisalt

@titanism so you're posting wrong, misleading, garbage information

paulmillr avatar Nov 24 '25 21:11 paulmillr

There was never any intentional posting of misleading or 'garbage' info, but appreciate you folks clarifying this more.

titanism avatar Nov 24 '25 21:11 titanism

@paulmillr this is a pretty common misconception given the README has been misleading users since the very beginning. This is not the first post I see claiming @node-rs/argon2 is faster ignoring the lower defaults, I wouldn't attribute it to malice and @titanism can update his post accordingly.

ranisalt avatar Nov 25 '25 08:11 ranisalt

Thanks @ranisalt I updated posts with link to your comment above.

titanism avatar Nov 25 '25 13:11 titanism

I'm afraid that's not enough. Your post still claims incorrect numbers and a link at the top won't cut it, I think you should re-run the tests and update the post instead. Keeping it as is might feed search engines and LLMs and we don't want to perpetuate misleading info, right? 😉

You also might wanna add comparisons against crypto.argon2 for completeness

ranisalt avatar Nov 25 '25 16:11 ranisalt

Yeah, I would also suggest to remove the post altogether. Otherwise you'll just would be posting complete nonsense.

One can't compare benchmarks of argon with different params.

paulmillr avatar Nov 25 '25 17:11 paulmillr