DEV Community

Cover image for I built a 3KB alternative to replace zxcvbn (389KB) without detection loss
Fayaz F
Fayaz F

Posted on

I built a 3KB alternative to replace zxcvbn (389KB) without detection loss

zxcvbn is the most widely used password strength estimator with 1M npm downloads a week. It's also 389KB gzipped and hasn't shipped a commit since 2017. Most sign-up forms are hauling that around just to block password123.

Poor password UX is a real conversion problem. A strength meter that adds 389KB to your bundle delays page load — on mobile, measurably so. Users who hit a slow registration page don't wait. They leave. The irony is that most of that weight goes toward catching passwords nobody is actually using to register on your site.

So I built passcore - 3.0KB gzipped and 98.4% detection rate on real breach data - same as zxcvbn, benchmarked against a deduped list of passwords pulled live from RockYou, Adobe, HIBP, and other major leak lists.

zxcvbn takes ~9.7ms to load — it's parsing 389KB of dictionary into memory on every cold start. passcore loads in ~0.2ms. It evaluates a password in ~2,600 nanoseconds. For a registration form, it's effectively invisible — no jank, no layout shift, no contribution to your Core Web Vitals score. The strength meter shows up before the user finishes typing their first character.

How it works:

passcore runs five detection layers on every password:

  1. Dictionary - All entries sourced directly from breach data, not a generic word list
  2. Keyboard patterns - qwerty , asdf , 1234 , numpad walks
  3. Repeats - aaaa , ababab
  4. Sequences - abcdef , 123456
  5. L33t speak - decodes p@ssw0rdpassword , m0nk3ymonkey , then dictionary lookup

The dictionary is small by design. Every entry was chosen because it appears in real breach data - not because it's a common English word. Password1! is caught not by a 40k word list but by stripping the suffix and checking if the core word is in the breach list. It is.

The scoring model:

passcore returns a score from 0 to 4 - same scale as zxcvbn.

The detection layers run first. A dictionary match, keyboard pattern, repeat, sequence, or l33t substitution scores 0 or 1 immediately - no further calculation. If none of those fire, scoring falls through to length and character variety: uppercase, lowercase, digits, symbols. A password that clears all five layers but is only 6 characters long still scores low.

There's also a length floor, aligned with NIST SP 800-63B: passwords 20+ characters score at least 3, passwords 30+ characters score 4, regardless of character variety. A passphrase like correct-horse-battery-staple is vastly harder to crack than P@ss1 - the scoring reflects that.

The research:

Getting to 98.4% detection required more than a dictionary lookup. A few problems that came up during development:

Word+affix patterns: Password1!, Admin123, Welcome1 - extremely common in breach data, none of them are in any dictionary as-is. The fix was a matchCommonRoot layer: strip leading and trailing non-alpha characters, check if what's left is a breach word. It is, every time, for this class of password.

L33t speak with separators: N0=Acc3ss decodes to no=access. A naive l33t decoder finds no dictionary match and passes it. The fix was to split the decoded string on non-alpha characters and check each segment independently. access is in the breach list. Caught.

Missing critical roots: Running against real breach lists exposed that admin, test, user, login, pass weren't in the dictionary - meaning Admin123, test1234, user2024 all slipped through. Added those five. Caught.

Switching looks like this:

// before
import zxcvbn from 'zxcvbn';
const { score } = zxcvbn(password);

// after
import { passcore } from 'passcorelib';
const { score } = passcore(password);
Enter fullscreen mode Exit fullscreen mode

One caveat: result.feedback.warning becomes result.warning, making it one level flatter.

zxcvbn zxcvbn-ts passcore
Bundle (gzipped) 389 KB 855 KB 3.0 KB
Speed 77,578 ns/op 839,991 ns/op 2,622 ns/op
Detection rate 98.4% 98.4% 98.4%
Maintained No Yes Yes

The tradeoff:

The tradeoff is dictionary size: 329 entries vs 40k+. But the passwords responsible for most credential stuffing aren't obscure literary references - they're Password1!, baseball123, keyboard walks, and l33t variants of the top breach list. passcore catches those.

So that's the bet passcore makes: that 329 targeted entries catch more of what actually matters than 40,000 words that cover everything, including passwords no one uses and/or no attacker is trying. The benchmark agrees — 98.4% detection rate across 370 real breach passwords, same as zxcvbn, at 130x less weight. For the 1% that need exhaustive coverage, use zxcvbn.

TL;DR — zxcvbn is 389KB and abandoned. passcore is 3KB, same detection rate, actively maintained. If bundle size matters to you, it's a near drop-in swap.

GitHub · npm

Top comments (0)