cl-brotli

4 min read Original article ↗

Common Lisp bindings for the brotli compression library, providing Gray-stream-based compression and decompression.

Status

Functional. Used as the brotli compression backend for datastar-cl. That said, not thoroughly tested.

Reference implementation

This library was inspired by cl-zstd by Guillaume Le Vaillant: the file structure, gray-stream class design, API naming, and test approach all follow cl-zstd as closely as possible.

This is intentional: cl-zstd is a clean, well-structured library and cl-brotli is meant to be readable alongside it, but even more relevant for this choice, cl-zstd was already supported for SSE streams, making it a good starting point: not being an expert in compression streams, having a reference implementation to follow was fundamental.

Where the brotli C API forces a deviation, the source code includes a short comment explaining why. Readers familiar with cl-zstd should be able to read cl-brotli with minimal friction. The actual implementation is completely different, being based on two different libraries (libbrotli vs libzstd).

System requirements

The brotli shared libraries must be installed:

# Debian/Ubuntu
apt install libbrotli1 libbrotli-dev

etc.

Usage

This is a library so usage will be done by applications; the following snippets show some of the common entry points:

  (ql:quickload :cl-brotli)

  ;; One-shot
  (let* ((data #(1 2 3 4 5))
         (compressed   (brotli:compress-buffer data))
         (decompressed (brotli:decompress-buffer compressed)))
    (equalp data decompressed))  ; => T

  ;; Streaming (encoder)
  (with-open-file (out "/tmp/data.br" :direction :output :element-type '(unsigned-byte 8))
    (brotli:with-compressing-stream (stream out :level 4)
      (write-sequence (cl-octet-streams:string-to-octets "some-bytes") stream)))

  ;; Streaming (decoder)
  (with-open-file (in "/tmp/data.br" :element-type '(unsigned-byte 8))
    (brotli:with-decompressing-stream (stream in)
      (read-sequence buffer stream)))

An Huchentoot minimal example is in examples/server.lisp:

  $ sbcl --load examples/server.lisp 
  [...]
  Serving on http://localhost:4242/hello  (C-c to stop)
  [...]
  127.0.0.1 - [2026-06-03 20:25:45] "GET /hello HTTP/1.1" 200 - "-" "curl/8.20.0"

Using curl:

  $ curl  --compressed -v http://localhost:4242/hello
  [...]
  > GET /hello HTTP/1.1
  > Host: localhost:4242
  > User-Agent: curl/8.20.0
  > Accept: */*
  > Accept-Encoding: deflate, gzip, br, zstd
  [...]
  < Server: Hunchentoot 1.3.1
  < Transfer-Encoding: chunked
  < Vary: Accept-Encoding
  < Content-Encoding: br
  < Content-Type: text/plain; charset=utf-8
  [...] 
  Hello, Brotli!

Compression levels

Brotli quality ranges from 0 (fastest) to 11 (best compression, slowest).

  • Default in this library: 4. Fast, adequate for real-time streaming (e.g., SSE which was the initial use-case).
  • Brotli's upstream default: 11. Designed for static asset pre-compression, usually too slow for per-event streaming.

API

The API mirrors cl-zstd closely; this table shows the initial mapping:

cl-brotli cl-zstd equivalent
brotli:make-compressing-stream zstd:make-compressing-stream
brotli:with-compressing-stream zstd:with-compressing-stream
brotli:make-decompressing-stream zstd:make-decompressing-stream
brotli:with-decompressing-stream zstd:with-decompressing-stream
brotli:compress-buffer zstd:compress-buffer
brotli:compress-stream zstd:compress-stream
brotli:compress-file zstd:compress-file
brotli:decompress-buffer zstd:decompress-buffer
brotli:decompress-stream zstd:decompress-stream
brotli:decompress-file zstd:decompress-file
brotli:brotli-error zstd:zstd-error

Benchmarks

A simple benchmark test was added to the test system; load it and call run-benchmarks:

(ql:quickload :cl-brotli-tests)
(brotli-benchmarks:run-benchmarks)

Three workloads are measured across 256 B / 4 KiB / 64 KiB / 1 MB payloads of "realistic" SSE-style text (event: / data: lines with varied counters). The following is captured:

  • round-trip: one-shot batch API, compress-buffer + decompress-buffer per iteration.
  • stream-flush: streaming (like SSE), one long-lived encoder, one finish-output per event. This is the workload that matters for real-time SSE use.
  • decomp-stream: streaming decode, a fresh make-decompressing-stream per iteration.

Reading the output

  • Wall(s): elapsed real time ("wall clock time") for all iterations combined.
  • MiB/s: input throughput (uncompressed bytes / wall time).
  • Ratio: compressed bytes / input bytes; below 1.0 means compression is working. Ratios improve with payload size because brotli's back-reference window covers more data. decomp-stream is always -- since decompression has no quality setting.

Quality 4 (the default) is chosen for streaming; brotli's upstream default of 11 is designed for static asset pre-compression and is too slow per-event.

Use :level (0–11) and :iterations to explore the speed/ratio trade-off:

(brotli-benchmarks:run-benchmarks :level 0)   ; fastest encoder, lower ratio
(brotli-benchmarks:run-benchmarks :level 9)   ; slower encoder, better ratio

For a flame-style sb-sprof profile, use the make flame target.

Documentation

This package uses declt for documentation, and there's a "docs" target in the Makefile that creates the Texinfo file and converts it to html: makeinfo is a requirement for this.

License

GNU LGPL v3 or later

Additional info

Author: Frederico Muñoz / ΛↃ lambda combine