Codec selection
Synkronyx Image originally encoded through sharp, a native binding to libvips. It now encodes through WebAssembly. This page records the measurements that drove that decision, so the reasoning can be checked rather than taken on trust.
Every figure below was produced on a 640 pixel wide photographic source. Reproduce them with npm run test:codecs.
Why the encoder changed
Section titled “Why the encoder changed”sharp is fast, but it is a native module. Its binaries are platform specific, which forced a release matrix.
| Native encoder | WebAssembly encoder | |
|---|---|---|
| Binary size | 19 MB per platform | one build for all platforms |
| Release artefacts | 5 platform packages | 1 universal package |
| Runs in a browser | No | Yes |
| Extension install failure risk | Wrong platform package fails at runtime | None |
The deciding factor was not size. It was that a single package cannot serve every platform when the encoder is native, and that a native encoder can never run in vscode.dev, in a progressive web app, or on a phone.
Candidates considered
Section titled “Candidates considered”| Library | Size | Outcome |
|---|---|---|
wasm-vips |
11.9 MB | Rejected. Closest API match to sharp, but six times the size of the alternative. |
@jsquash/* |
1.8 MB core | Selected. Codecs and resampling only, so pixel operations move into shared code. |
photon-rs |
n/a | Rejected. Weaker format coverage. |
Choosing codec-only libraries turned out to be an architectural benefit rather than a compromise. Brightness, contrast, grayscale, inversion, sepia, cropping, and blur are plain arithmetic over RGBA. They now live in @sknx/image-core as pure functions with no encoder dependency, shared by every surface instead of being expressed as encoder-specific pipeline calls.
Output quality against the native encoder
Section titled “Output quality against the native encoder”The concern with replacing a mature native encoder is silent quality loss. Both encoders were run over the same source at three quality levels. RMSE is root mean square error against the original: lower is closer to the source.
Quality 60
Section titled “Quality 60”| Format | Native size | WebAssembly size | Difference | RMSE native | RMSE WebAssembly |
|---|---|---|---|---|---|
| PNG | 166.8 KB | 76.6 KB | -54% | 3.55 | 3.92 |
| JPEG | 19.1 KB | 18.8 KB | -1% | 2.62 | 2.66 |
| WebP | 12.1 KB | 12.1 KB | 0% | 2.87 | 2.87 |
| AVIF | 14.8 KB | 12.7 KB | -14% | 1.80 | 2.18 |
Quality 80
Section titled “Quality 80”| Format | Native size | WebAssembly size | Difference | RMSE native | RMSE WebAssembly |
|---|---|---|---|---|---|
| PNG | 184.0 KB | 81.9 KB | -55% | 2.55 | 3.74 |
| JPEG | 33.8 KB | 33.5 KB | -1% | 1.18 | 1.18 |
| WebP | 18.0 KB | 18.0 KB | 0% | 2.30 | 2.30 |
| AVIF | 25.9 KB | 23.9 KB | -8% | 1.12 | 1.37 |
Quality 95
Section titled “Quality 95”| Format | Native size | WebAssembly size | Difference | RMSE native | RMSE WebAssembly |
|---|---|---|---|---|---|
| PNG | 184.0 KB | 86.8 KB | -53% | 2.55 | 3.68 |
| JPEG | 45.7 KB | 45.7 KB | 0% | 0.52 | 0.52 |
| WebP | 50.9 KB | 50.9 KB | 0% | 1.20 | 1.20 |
| AVIF | 50.1 KB | 42.4 KB | -15% | 0.59 | 0.82 |
WebP output is byte identical at every level, because both encoders wrap the same libwebp implementation. JPEG is identical at quality 80 and 95 once configured to match.
Two defects the measurements exposed
Section titled “Two defects the measurements exposed”Neither of these was visible from a build passing or a file being produced. Both required measuring the output.
JPEG inflated by 37% at high quality
Section titled “JPEG inflated by 37% at high quality”The first JPEG configuration produced output 37% larger than the native encoder at quality 95, while RMSE stayed level. Larger and marginally worse rules out a simple quality-scale difference.
Dumping the encoder defaults showed the cause:
{ "auto_subsample": true, "chroma_subsample": 2 }auto_subsample makes mozjpeg ignore the supplied chroma setting and choose its own at high quality, dropping to 4:4:4. Setting chroma_subsample alone changed nothing, which is exactly the signature of an ignored option. Disabling auto_subsample produced identical RMSE at every quality level.
PNG was initially 8% larger
Section titled “PNG was initially 8% larger”The first PNG implementation passed no options at all, producing 198 KB where the native encoder produced 184 KB. PNG output now applies the same palette budget the native encoder used below quality 100, followed by lossless optimisation through @jsquash/oxipng. That turned an 8% regression into output roughly half the size.
The higher PNG RMSE is inherent to palette quantisation and is the same trade the native encoder made with palette mode enabled. This implementation quantises more aggressively, which is where the size reduction comes from.
Format coverage
Section titled “Format coverage”A 30 case matrix covering every input format against every output format passes in full.
| Input | PNG | JPEG | WebP | AVIF | GIF |
|---|---|---|---|---|---|
| PNG | Yes | Yes | Yes | Yes | Yes |
| JPEG | Yes | Yes | Yes | Yes | Yes |
| WebP | Yes | Yes | Yes | Yes | Yes |
| AVIF | Yes | Yes | Yes | Yes | Yes |
| GIF | Yes | Yes | Yes | Yes | Yes |
| BMP | Yes | Yes | Yes | Yes | Yes |
BMP remains input only, matching the previous behaviour.
The BMP decoder is hand written and was validated by a stronger check than “it did not throw”. Converting a BMP to PNG produced byte identical output to converting the equivalent PNG to PNG. Those two paths share only the encoder, so identical output means the decoder is pixel exact.
Animated GIF is decoded as its first frame, which matches the previous behaviour and the warning already shown in the editor.
| Operation | Native | WebAssembly |
|---|---|---|
| 2048x2048 resize, adjust, JPEG encode | 149 ms | 726 ms |
| 640 pixel JPEG encode | n/a | 46 ms |
| 640 pixel AVIF encode | n/a | 106 to 249 ms |
WebAssembly is roughly five times slower in relative terms, and comfortably under a second in absolute terms. AVIF was expected to be the problem case and is not.
Test coverage
Section titled “Test coverage”npm run test:codecs runs 24 tests.
Pixel operations, 13 tests. Exact channel values for brightness, contrast, all three grayscale weightings, inversion, sepia, and cropping. Resize geometry including aspect ratio locking. Validation limits for every request field, size limit parsing, and the quality retry ladder.
Codecs, 11 tests. Encode and decode for every advertised format, resize applied to encoded output verified by reading PNG header dimensions, quality affecting size, size ceilings honoured by stepping quality down, unreachable ceilings reported rather than silently exceeded, unsupported input rejected readably, placeholder dimensions and colour validation, BMP pixel exactness, BMP rejection of unsupported variants, and a GIF round trip.
These tests use only the WebAssembly encoder, so they remain valid after the native dependency is removed.