lz-string
lz-string compresses a JavaScript string into a shorter JavaScript string, with no dependencies and about 1.4 KB gzipped of code. It is an LZW variant tuned for the fact that browsers store strings as UTF-16, so it can pack more than 8 bits into every output character. You pick an output flavor depending on where the result is going: compressToUTF16 for localStorage, compressToEncodedURIComponent for URLs, compressToBase64 for anything that expects ASCII, compressToUint8Array for binary transport. Decompression is the matching function on the other side. It only handles strings, so objects go through JSON.stringify first.
Still the shortest path from a fat JSON blob to a localStorage key or a shareable URL, and 1.4 KB is hard to argue with. But it has not shipped a release since March 2023, and if your targets support CompressionStream you are paying bundle bytes for something the platform now does better.
Use it if
- You are hitting the roughly 5 MB localStorage or sessionStorage quota and the data is repetitive JSON, where this typically cuts it by more than half
- You want to put application state in a URL or query string and need it to survive copy-paste: compressToEncodedURIComponent emits only URL-safe characters, so no extra encodeURIComponent pass
- You need something that runs identically in a browser, in a service worker, and in Node with zero build config and zero dependencies
- You want the compression step to be synchronous, because CompressionStream is promise-based and awkward inside a synchronous storage wrapper
- You are on modern browsers or Node 18+ only. CompressionStream with 'gzip' or 'deflate' is built in, costs zero bytes of bundle, and beats an LZW variant on most payloads bigger than a few kilobytes
- Your data is not text. lz-string takes a string and nothing else, so binary goes through base64 first and you pay a 33 percent inflation before you compress anything
- Your strings are short. On inputs under roughly 100 characters the output is regularly longer than the input, so you need to measure and fall back to storing raw
- You need cross-language round-tripping. Every port listed in the README is a separate third-party project and the README itself warns to verify compatibility, plus version 1.3.8 changed the encoding
- You want an actively released package. npm has been on 1.5.0 since March 2023 while the GitHub README documents a version 2 file layout with ESM support that has never been published, so the docs on master describe something you cannot install
- You think of it as protection. Compressed localStorage is obfuscation, not encryption, and anyone with devtools open decompresses it in one line
Setup reality
Install is npm install lz-string and there is nothing to configure, but the published 1.5.0 tarball is a UMD file with main pointing at libs/lz-string.js, no exports map, no ESM build, and no package type field. Bundlers handle it fine but they cannot tree-shake it, so you ship all ten functions even if you call one. The bundled typings declare named exports only, so a default import fails typecheck under TypeScript unless allowSyntheticDefaultImports is on; use named imports or import star. The README on GitHub describes a version 2 layout with separate CommonJS and ESM entries, which is not what npm gives you. A global install also puts an lz-string CLI on your PATH.
Patterns
Store a compressed value in localStoragecompress-for-localstorage
import { compressToUTF16, decompressFromUTF16 } from 'lz-string';
localStorage.setItem('draft', compressToUTF16(JSON.stringify(draft)));
const raw = localStorage.getItem('draft');
const draft = raw ? JSON.parse(decompressFromUTF16(raw)) : null;Use the UTF16 pair here, not plain compress(). compress() emits unpaired surrogates that survive localStorage in most engines but get mangled the moment the value touches JSON, fetch, or a UTF-8 file.
Put state in a query string or hashcompress-for-url
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string';
const param = compressToEncodedURIComponent(JSON.stringify(filters));
history.replaceState(null, '', `?s=${param}`);
const s = new URLSearchParams(location.search).get('s');
const filters = s ? JSON.parse(decompressFromEncodedURIComponent(s)) : {};The output already uses only URL-safe characters, so do not wrap it in encodeURIComponent; doing so escapes nothing but does add length, and a stray decodeURIComponent on the way back can turn + into a space and break decompression.
Only keep the compressed form if it is smallerguard-against-growth
import { compressToUTF16 } from 'lz-string';
function pack(text) {
const packed = compressToUTF16(text);
return packed.length < text.length
? { z: 1, v: packed }
: { z: 0, v: text };
}Short or high-entropy strings compress to something longer than the input. A one-byte flag costs nothing and stops you from paying for compression that made things worse.
Handle corrupt or foreign inputdetect-decompress-failure
import { decompressFromUTF16 } from 'lz-string';
function safeLoad(raw) {
let text;
try {
text = decompressFromUTF16(raw);
} catch {
return null;
}
if (text === null || text === '') return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}Decompressing garbage does not reliably throw: you can get null, an empty string, or plausible-looking mojibake, so always validate the result rather than trusting an absent exception.
Produce plain ASCII outputcompress-to-base64
import { compressToBase64, decompressFromBase64 } from 'lz-string';
const body = compressToBase64(JSON.stringify(payload));
await fetch('/api/report', { method: 'POST', body });This is the flavor to use whenever the value crosses a UTF-8 boundary: HTTP bodies, cookies, JSON fields, log lines. It is roughly 33 percent bigger than the UTF16 flavor for the same input.
Get bytes for binary transportcompress-to-uint8array
import { compressToUint8Array, decompressFromUint8Array } from 'lz-string';
const bytes = compressToUint8Array(JSON.stringify(payload));
ws.send(bytes);
// on the other side
const text = decompressFromUint8Array(new Uint8Array(event.data));The 1.5.0 encoder pads to an even byte length, which is why the repo CLI has a --legacy flag; if you decompress with a different language port, check that it agrees on odd-length handling.
Import it without a typecheck errortypescript-import
// works: the bundled typings declare named exports only
import { compress, decompress } from 'lz-string';
// also works
import * as LZString from 'lz-string';
// fails typecheck unless allowSyntheticDefaultImports is on
// import LZString from 'lz-string';typings/lz-string.d.ts has no default export even though the runtime module.exports is a single object, so the default import is a compile-time problem, not a runtime one.
A drop-in compressed storage wrapperwrap-storage-api
import { compressToUTF16, decompressFromUTF16 } from 'lz-string';
export const zstore = {
set(key, value) {
localStorage.setItem(key, compressToUTF16(JSON.stringify(value)));
},
get(key, fallback = null) {
const raw = localStorage.getItem(key);
if (raw == null) return fallback;
const text = decompressFromUTF16(raw);
return text ? JSON.parse(text) : fallback;
},
};Namespace your keys or bump a version prefix when you change the format: existing users still have values written by the old scheme and there is no header telling you which is which.
Check the ratio on your real payloadmeasure-before-adopting
import { compressToUTF16 } from 'lz-string';
const sample = JSON.stringify(realWorldState);
const packed = compressToUTF16(sample);
console.log({
chars: sample.length,
packedChars: packed.length,
ratio: (packed.length / sample.length).toFixed(2),
});Compare against the native path before committing: repetitive JSON often lands near 0.3, while already-compressed or random data lands above 1.0.
The platform alternative worth measuring againstnative-compressionstream-fallback
async function gzipToBase64(text) {
const cs = new CompressionStream('gzip');
const stream = new Blob([text]).stream().pipeThrough(cs);
const buf = await new Response(stream).arrayBuffer();
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}Available in Node 18+ and current browsers, costs no bundle bytes, and usually wins on larger payloads; the trade-off is that it is async, which makes it awkward inside a synchronous storage shim.
Compress a file from the shellcli-compress-file
npm install -g lz-string
lz-string -e base64 input.json -o output.txt
lz-string -d -e base64 output.txtThe CLI ships in the published tarball at bin/bin.js; -e picks the encoder and it must match on both ends, since raw and base64 output are not interchangeable.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| fflate | npm | You want real DEFLATE with gzip and zip support, still small, and you need it to interoperate with server-side tooling |
| pako | npm | You need a full zlib port in JavaScript with streaming and tuning knobs, and bundle size is not the constraint |
| lzutf8 | npm | Your input is UTF-8 heavy text and you want async and worker-based compression out of the box |