
Your Crypto Library Is Bundle Bloat
Stop importing kilobytes of overhead for basic signing and encryption when the browser offers a hardened, hardware-accelerated alternative via the Web Cryptography API.
Your Crypto Library Is Bundle Bloat
Open your package.json and look for crypto-js, sjcl, or even the newer noble libraries. If you are using them for basic tasks like hashing a password, generating a SHA-256 checksum, or encrypting a small blob of data in the browser, you are shipping kilobytes of redundant logic that your user's browser already has built into its engine.
The Web Cryptography API (often accessed via window.crypto.subtle) is a low-level, hardware-accelerated, and highly secure interface for cryptographic operations. It has been supported in every major browser since roughly 2014. Despite this, the JavaScript ecosystem remains addicted to polyfills and "user-land" implementations that increase bundle size, slow down execution, and—most importantly—are often less secure than the native alternative.
The Cost of Convenience
When you npm install crypto-js, you aren't just adding a utility; you're adding roughly 400KB of unminified source code (around 60KB minified and gzipped) just to do things like MD5 or AES. On a slow 3G connection, that’s an extra half-second of parse time before your app even becomes interactive.
But it isn't just about the bytes. JavaScript-based crypto libraries run on the main thread and are subject to the same side-channel attacks as any other JS code. The Web Crypto API, conversely, is implemented by the browser vendors in C++ or Rust. It can take advantage of CPU instructions like AES-NI (Advanced Encryption Standard New Instructions), making it orders of magnitude faster and more power-efficient.
Starting Simple: The SHA-256 Hash
Hashing is perhaps the most common cryptographic task. Whether you're generating a cache key or verifying a file's integrity, you probably reach for a library. Here is how you do it natively.
async function hashMessage(message) {
const encoder = new TextEncoder();
const data = encoder.encode(message);
// The subtle.digest method returns a Promise that resolves to an ArrayBuffer
const hashBuffer = await Uint8Array.from(
await crypto.subtle.digest('SHA-256', data)
);
// Convert the buffer to a hex string
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return hashHex;
}
hashMessage("The quick brown fox jumps over the lazy dog")
.then(console.log);I'll admit, the boilerplate of converting an ArrayBuffer back into a hex string is annoying. It’s the primary reason people stick with libraries that provide a toString('hex') method. But wrapping this in a tiny utility function is a one-time cost of about 10 lines of code, compared to the thousands of lines in a third-party library.
Why "SubtleCrypto"?
You’ll notice the API lives under crypto.subtle. The W3C chose this name as a warning: cryptography is "subtle" and easy to break if you don't know what you're doing. The API doesn't hold your hand. It won't automatically generate an Initialization Vector (IV) for your encryption; it won't choose a safe number of iterations for your password PBKDF2.
This leads many developers back to the "friendly" libraries. However, that friendliness often masks dangerous defaults. By using the native API, you are forced to confront the parameters of your security model, which is arguably where you should be if you're handling sensitive data.
Symmetric Encryption with AES-GCM
If you need to encrypt data that only your app (or your server) will decrypt later, AES-GCM is the gold standard. It provides both confidentiality and integrity (it detects if the data was tampered with).
Here is a practical example of generating a key, encrypting a string, and then decrypting it.
async function encryptData(text, password) {
const encoder = new TextEncoder();
// 1. Derive a key from a password
const salt = crypto.getRandomValues(new Uint8Array(16));
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
const key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
// 2. Encrypt
const iv = crypto.getRandomValues(new Uint8Array(12)); // GCM standard IV length
const encodedData = encoder.encode(text);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
encodedData
);
return {
encrypted,
iv,
salt
};
}Notice crypto.getRandomValues(). This is another native gem. It's a cryptographically strong pseudo-random number generator (CSPRNG). If you are using Math.random() for anything security-related, you are making a massive mistake. Math.random() is predictable. crypto.getRandomValues() is seeded by the operating system's entropy pool.
The "Non-Exportable" Power Move
One of the coolest features of the Web Crypto API that no JavaScript library can replicate is non-exportable keys.
When you generate a key in crypto-js, that key is just a string of bits sitting in your application's memory. If an attacker manages to execute a Cross-Site Scripting (XSS) attack on your site, they can simply read that variable and steal your key.
With the Web Crypto API, you can generate a key and set the extractable flag to false.
const secureKey = await crypto.subtle.generateKey(
{
name: "ECDSA",
namedCurve: "P-256",
},
false, // This is the 'extractable' flag. Set to false!
["sign", "verify"]
);Once that key is created, there is no way for JavaScript to read the raw private key bits. You can use the key to sign a message or verify a signature, but you can't console.log it or send it to a malicious server. The key is essentially trapped within the browser's secure internal storage. This is a level of defense-in-depth that is physically impossible to achieve with a pure JavaScript library.
Digital Signatures: Replacing JWT Libraries
We often use libraries like jose or jsonwebtoken (if we're in Node) to sign data. If you're doing this in the browser—perhaps to sign a request before sending it to an API—you can do this natively with ECDSA (Elliptic Curve Digital Signature Algorithm).
async function signMessage(message, privateKey) {
const encoder = new TextEncoder();
const data = encoder.encode(message);
const signature = await crypto.subtle.sign(
{
name: "ECDSA",
hash: { name: "SHA-256" },
},
privateKey,
data
);
return signature;
}This is incredibly fast. If you're building a decentralized app or a zero-knowledge system, using native ECDSA saves your users' battery life and keeps your bundle lean.
Dealing with the "Buffer" Problem
The biggest hurdle for most developers is that the Web Crypto API works exclusively with ArrayBuffer and TypedArray (like Uint8Array). We are used to strings.
I find that I usually keep a small "crypto-utils.js" file in my projects. It’s much smaller than a library and handles the boilerplate.
// crypto-utils.js
export const bufferToHex = (buffer) =>
Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
export const hexToBuffer = (hex) => {
const view = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
view[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return view.buffer;
};
export const strToBuffer = (str) => new TextEncoder().encode(str);
export const bufferToStr = (buf) => new TextDecoder().decode(buf);With these 20 lines, the "usability" argument for crypto-js virtually evaporates.
Performance: A Real World Stress Test
I once worked on a project that needed to decrypt 50MB of local data on page load. Initially, we used a popular JS-based AES library. On a mid-range Android phone, the decryption took nearly 8 seconds, completely freezing the UI thread.
We refactored it to use SubtleCrypto. The decryption time dropped to under 600ms. Because the browser can offload these calculations to the hardware, the UI remained responsive. If your app feels "janky" while performing cryptographic operations, your library is likely the bottleneck.
The Node.js Factor
A common rebuttal is: "I need my code to be isomorphic! I want the same crypto on the frontend and backend."
Good news: Node.js has supported the Web Crypto API since version 15. You can access it via require('crypto').webcrypto (or it's globally available in newer versions). Deno and Bun also support it natively. You can finally write one set of cryptographic logic that runs everywhere without dragging node-forge or crypto-js into your frontend bundle.
When Should You Actually Use a Library?
I'm not a fundamentalist. There are legitimate reasons to use a library:
1. Legacy Algorithm Support: If you are forced to interface with a legacy system using TripleDES or Blowfish, Web Crypto won't help you. It only supports modern, secure algorithms (AES, RSA, ECDSA, ECDH, SHA-2).
2. Streaming: The Web Crypto API is atomic; it expects the whole buffer at once. If you're encrypting a 2GB file in chunks via streams, a library like noble-ciphers or Node's native crypto.createCipheriv is better suited.
3. Password Hashing (Argon2/scrypt): Web Crypto supports PBKDF2, which is "okay," but modern standards prefer Argon2. Since Argon2 isn't in the Web Crypto spec yet, you'll need a WASM-based implementation for that.
4. Complex Protocols: If you're implementing Signal Protocol or complicated Multi-Party Computation, use a vetted library. Don't roll your own protocol with primitives.
The Strategy for Migration
Don't go and delete all your dependencies tonight. Start small.
Next time you need to generate a UUID, don't install the uuid package. Use:crypto.randomUUID() (supported in all modern browsers).
Next time you need to hash a string for a gravatar URL or a cache key, use the hashMessage function I wrote above.
The "Bundle Bloat" in the JavaScript ecosystem is often a result of us reaching for npm install before checking the MDN documentation. The Web Cryptography API is a high-performance, secure, and zero-byte-cost tool sitting right under your nose. It’s time we started using it.
Summary Checklist for Switching
* Check compatibility: Are you supporting IE11? (If yes, stick to libraries. If no, go native).
* Identify Primitives: Are you just doing AES, SHA-2, or RSA? These are the Web Crypto sweet spots.
* Audit your `package.json`: Run npm list or use a bundle analyzer. If crypto-js is taking up 15% of your main bundle for a single SHA-256 call, it’s time to refactor.
* Embrace the Buffer: Get comfortable with Uint8Array. It's the language of the web's lower level.
Stop paying the "library tax" for features your users already downloaded when they installed their browser. Your bundles will be smaller, your code will be faster, and your security posture will be stronger.


