Das Komprimieren von mittelgroßen JSON-Payloads ist im Web-Frontend keine so ungewöhnliche Anforderung. Ein ausreichend kleines JavaScript-Objekt kann in eine Base64-Repräsentation eines JSON-Strings verwandelt werden, was es wiederum ermöglicht, Applikationsdaten in URLs vorzuhalten. Auf diese Weise können Webapps Speicher- (via Bookmark) und Sharing-Features (via Copy-and-paste der URL) anbieten, ohne ein komplexes Backend zu benötigen, denn ihre URLs sind die Datenbanken. Beispiele für solche Apps sind Babel-Sandbox und der TypeScript-Playground. Beide speichern ihre Applikations-States in ihren URLs – den Source Code Base64-codiert, den Rest als ganz normale Query-Parameter. Und da ich im Moment etwas ganz Ähnliches vorhabe, machte ich mich auf den Weg, die Implementierungen von Babel- und TypeScript-Sandbox zu erforschen und den State-in-URL-Ansatz 1:1 zu stibitzen. Jedenfalls war das der Plan.

lz-string

Dank Open Source war schnell klar, wie die Babel- und TypeScript-Sandboxes funktionieren: Beide verwenden die extrem gut abgehangene JavaScript-Library lz-string, die mithilfe des ebenfalls extrem gut abgehangenen LZW-Algorithmus JavaScript-Strings komprimiert und die Ergebnisse direkt als URL-kompatibles Base64 ausspuckt. Die Kompression reduziert die Länge der jeweiligen Code-Samples und die Base64-Codierung macht das Endergebnis für URLs besser verdaulich. Im Prinzip können URLs auch Unicode enthalten und laut eines Papers der University of Stack Overflow können URLs im Prinzip sehr sehr lang werden, aber im Sinne der besseren Handhabbarkeit sind Kompression und Base64 schon sehr sinnvoll.

lz-string ist ziemlich großartig. Die Library ist extrem stabil, einfach zu benutzen, dabei trotzdem vielseitig und sie kommt mit exakt null eigenen Dependencies daher. Leider stellte sie sich aber als trotzdem nicht wirklich brauchbar für meine Zwecke heraus … denn der Applikations-State, den ich in URLs unterzubringen gedenke, ist viel größer als der State der Babel- und TS-Sandboxes.

Bei allen Vorzügen hat die lz-string-Library doch eine Eigenschaft, die nicht nur positiv ist: Der LZW-Algorithmus ist über 40 Jahre alt und nicht ganz so leistungsstark wie modernere Verfahren. Also sah ich mich veranlasst, NPM nach Alternativen zu durchwühlen und fand wenig Brauchbares. Abgesehen vom allgegenwärtigen Qualitätslimbo ist eine Grundregel von Datenkompression, dass Verbesserungen Kosten haben. Moderne Algorithmen sind viel komplexer als LZW und bezahlen für ihre besseren Kompressionsraten mit teilweise signifikant längeren Laufzeiten und/oder erheblich größeren JS-Bundles. Für Web-Frontends stellt lz-string offenbar einen ziemlich optimalen Kompromiss dar – dumm nur, dass dieser Kompromiss für meine Pläne schlichtweg nicht genug Kompressionsleistung mitbrachte. Mir grauste es bereits vor der Auseinandersetzung mit Web Workers oder WASM, bis mir per Zufall auffiel, dass ich für mein Vorhaben nicht eine andere JavaScript-Libraray benötigte, sondern einfach gar keine!

Compression Streams API

Alle moderneren Browser (sowie Deno und Node) unterstützen offenbar seit Ewigkeiten die Compression Streams API, mit der wir JavaScript-Autor:innen ohne irgendwelche Dependencies Zugriff auf DEFLATE und die DEFLATE-Wrapper gzip und zlib bekommen! Der Deflate-Algoritmus und seine Wrapper-Formate sind schließlich für HTTP-Kompression ohnehin in jedem Browser vorhanden und eine JavaScript-Durchbindung anzubieten ist nicht die verrückteste Idee. Unbekannt war mir die API trotzdem und leicht zu entdecken war sie in der Flut der mittelmäßigen NPM-Packages und AI-generiertem SEO-Spam auch nicht.

Die API ist noch kein fertiger Webstandard und es gibt diverse offene Fragen und Feature Requests, aber die grundsätzliche Funktionalität ist in allen Browsern und JS-Runtimes verfügbar:

const deflateStream = someStream.pipeThrough(
  new CompressionStream("deflate-raw")
);
const compressedData = new Blob(
  await Array.fromAsync(deflateStream)
);

Zur Erklärung:

  • someStream im obigen Beispiel ist ein ReadableStream, der u. a. aus Responses oder Blobs stammen kann
  • neben deflate-raw (DEFLATE, RFC1951) stehen im Moment nur die DEFLATE-Wrapper-Formate deflate (ZLIB, RFC1950, benannt nach seinem entsprechenden HTTP Content-Encoding) und gzip (GZIP, RFC1952) zur Verfügung. Weitere Formate wie Brotli sind in die Diskussion. Die aktuellen APIs erlauben keine Änderung der Standard-Parameter der diversen Formate, insbesondere können keine Dictionaries angegeben werden (Bug #27)
  • Der Blob compressedData verwendet Array.fromAsync(), um den ReadableStream via Async Iteration zu konsumieren und den Inhalt in ein Array zu überführen.

Um mit der API meinen Use Case der App-State-Kompression umzusetzen, brauchte es noch ein paar weitere Zeilen für das Handling von JSON, Base64 und Unicode, aber nicht besonders viele:

async function compress(inputData) {
  const json = JSON.stringify(inputData);
  const deflateStream = new Blob([json])
    .stream()
    .pipeThrough(new CompressionStream("deflate-raw"));
  let binString = "";
  for await (const bytes of deflateStream) {
    binString += String.fromCodePoint(...bytes);
  }
  return btoa(binString);
}

async function decompress(inputBase64) {
  const binString = atob(inputBase64);
  const inputBytes = Uint8Array.from(binString, (s) => s.codePointAt(0));
  const inflateStream = new Blob([inputBytes])
    .stream()
    .pipeThrough(new DecompressionStream("deflate-raw"));
  let json = "";
  for await (const bytes of inflateStream) {
    json += new TextDecoder().decode(bytes);
  }
  return JSON.parse(json);
}

const appState = { user: "foo@bar.de", password: "hunter2" };

const compressedState = await compress(appState);
// Ergebnis: "q1YqLU4tUrJSSsvPd0hKLNJLSVXSUSpILC4uzy9KAYpnlOaVpBYZKdUCAA"

const decompressedState = await decompress(compressedState);
// Ergebnis:  { user: "foo@bar.de", password: "hunter2" }

Diese Implementierung verzichtet auf das Stand Anfang 2024 noch nicht universell unterstützte Array.fromAsync() sowie auf FileReader. Mit FileReader ist das Lesen von Blobs als Base64 und Text nicht nur asynchron, sondern im Prinzip einfacher, aber mangels Promise-Unterstützung auch nicht gänzlich unumständlich. Außerdem stellt sich schnell heraus, dass wir mit Compression Streams auf den Performance-Bonus des asynchronen FileReader bequem verzichten können.

Compression Streams und lz-string im Vergleich

Für meine Bedürfnisse gewinnt Compression Streams gegen lz-string schon allein dadurch, dass es (de facto) ein Webstandard ist. Zwar ist lz-string als Library ein Traum – klein, einfach zu benutzen, vielseitig, ohne eigene Dependencies – aber null Dependencies sind immer noch besser als eine Dependency. Einen hemdsärmligen Performance-Vergleich habe ich aber trotzdem mal angestellt und meine compress()-Funktion gegen compressToEncodedURIComponent() aus lz-string antreten lassen. Als Inputs dienten:

  • Das Winz-Objekt { user: "foo@bar.de", password: "hunter2" }
  • 23k mit json-generator.com erzeugte Zufallsdaten mit vergleichsweise wenig Redundanzen
  • Ein 14k großes Objekt, das einen App-State von meinem Projekt enthält … mit vergleichsweise vielen Redundanzen, v.a. in den Objekt-Keys

Die Laufzeit-Performance von Compression Streams ist immer um ein Vielfaches besser als die von lz-string. Das soll nicht bedeuten, dass lz-string langsam ist, sondern nur, dass Compression Streams noch schneller sind. Für meinen Use Case der periodischen App-State-Speicherung wäre beides locker ausreichend.

In Hinblick auf die Kompressionsrate schlagen Compression Streams auch immer lz-string, aber der Vorsprung ist nicht immer identisch ausgeprägt. Beim sehr kleinen Objekt ist die Differenz vernachlässigbar, bei den zufälligen Daten sind Compression Streams ca. 30% besser und bei meinem hochredundanten App-State fast 3x besser. Die Struktur des letztgenannten Objekts enthält eine ganze Reihe von Redundanzen und komprimiert daher natürlich besonders gut.

Letztlich sind für mich und meinen Use Case Compression Streams ein klarer Sieger gegen lz-string: Sie sind schneller, besser und ohne Dependencies auf jeder Plattform inkl. der Serverseite verfügbar.

Weitere Alternativen zu Compression Streams

Compression Streams haben in meinen Augen drei nennenswerte Vorteile:

  1. Null Dependencies
  2. Cross-Platform-Verfügbarkeit (alle Browser + Node + Deno)
  3. De-Facto-Webstandard (hohe zu erwartende Stabilität)

Fairerweise muss man aber auch sagen, dass es das wirklich alle Vorteilen sind, die Compression Streams zu bieten haben. Wer sich mit der Installation von Dependencies oder der Beschränkung auf einzelne Plattformen anfreunden kann, kann sich eine ganze Reihe anderer Vorteile ins Haus holen:

  1. Außer DEFLATE und seinen Wrappern haben Compression Streams zurzeit keine weiteren Algorithmen anzubieten. Viel moderne Kompressionsverfahren wie etwa Brotli werden von Libraries wie brotli-wasm unterstützt, aber eben um den Preis von Dependency-Installation und WASM-Gefummel.
  2. Compression Streams bieten z.Z. keine API zum Konfigurieren des DEFLATE-Algorithmus, was abhängig von den zu komprimierenden Daten ein ernsthaftes Hindernis darstellen kann. JS-Implementierungen wie deflate-js haben diesbezüglich deutlich mehr zu bieten.
  3. Wer auf Browser-Unterstützung verzichten kann, findet in den Server-JS-Plattformen moderne und konfigurierbare Kompressions-Algorithmen (z.B. Nodes eingebautem Zlib-Modul) vor.

Um es ganz deutlich zu machen: je nach anstehender Aufgabe können Compression Streams mit ihren Einschränkungen bzgl. Algorithmen und Konfigurierbarkeit komplett unbrauchbar sein. Wer für Megabytes an Game-Assets maximale Kompression benötigt, wird mit einer für ein paar Kilobytes JSON ausreichenden Lösung nicht glücklich werden. Wer ohnehin schon über 9000 Dependencies installiert hat und bereits in den Kessel mit WASM-Zaubertrank gefallen ist, hat keine Nachteile durch die zusätzliche Installation von brotli-wasm. Aber wer einfach nur ein bisschen JSON in URLs oder Local Storage quetschen möchte, ist mit Compression Streams durch den Browser out of the box hervorragend versorgt.

Compression Streams auf dem Stand von Anfang 2024 ist definitiv nicht unter allen Umständen die erste Wahl, aber wie so viele Webstandards eine 80/20-Lösung, die bei nüchterner Betrachtung für viele Anwendungsfälle ausreichend ist. Speziell für den Use Case der in URLs (oder Local Storage) hinterlegten App-States sind Compression Streams völlig okay und sogar in allen Belangen besser als das sonst hierfür so populäre lz-string.