I built a 3KB alternative to replace zxcvbn (389KB) without detection loss
Source: Dev.to
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: Dictionary - All entries sourced directly from breach data, not a generic word list qwerty , asdf , 1234 , numpad walks aaaa , ababab abcdef , 123456 p@ssw0rd → password , m0nk3y → monkey , 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);
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