Automating Image Optimization with a WASM Build Pipeline 2025 — A Playbook for esbuild and Lightning CSS
Published: Sep 29, 2025 · Reading time: 6 min · By Unified Image Tools Editorial
For web engineers who juggle Jamstack builds and edge delivery, the question "how much image work should happen at build time versus on-demand" is never-ending. As of 2025, the WASM (WebAssembly) ecosystem finally gives us fast compilers and optimizers that run inside Node.js without GPU dependencies, yet still ship production-grade derivatives. This guide shows how to assemble a build pipeline around esbuild and Lightning CSS, augmented with Squoosh CLI and AVIF encoders compiled to WASM, and wire the whole thing into CI/CD.
TL;DR
- Three-layer WASM bundle: (1) compile TypeScript + WASM plugins with esbuild, (2) run Squoosh CLI inside Worker Threads, (3) let Lightning CSS auto-generate
image-set()
. - Declarative derivative rules: define size/format/quality inside
assets.manifest.json
, then load it in an esbuild plugin. - CI reproducibility: reuse lightweight
.uasset
caches instead of Git LFS blobs, and automatically regenerate when hashes diverge. - Local-first validation: pair Playwright with the Image Compare Slider to surface visual artifacts before merging.
- Fallbacks and signing: merge ICC profiles into WebP/AVIF via Advanced Converter and keep a C2PA-signed PNG as the ultimate fallback.
Pipeline Overview
Layer | Role | Primary Tools | Outputs | Validation Focus |
---|---|---|---|---|
Source | Define derivative rules and manage base assets | assets.manifest.json , Git LFS | Input PNG/TIFF, metadata | ICC presence, copyright metadata |
Build (WASM) | Transform images with WASM workers | esbuild, Squoosh CLI, AVIF-wasm | AVIF/WebP/JPEG XL, asset-map.json | Size reduction, quality thresholds, metadata retention |
Style | Inject derivatives into CSS | Lightning CSS, PostCSS | *.css , image-set() | content-type , sizes consistency |
Observability | Diffing and signing | Image Compare Slider, Advanced Converter (AVIF/WebP), C2PA CLI | Snapshots, signed PNG | ΔE2000 tolerance, signature verification |
Delivery | Serve via CDN/edge | Vercel/Cloudflare, R2/S3 | Cached derivatives | MIME, Cache-Control, ETag |
Control Derivatives with Declarative Manifests
Ad-hoc npm
scripts lead to tribal knowledge. Instead, capture intent in a JSON manifest.
{
"hero-landing": {
"source": "assets/hero-source.tif",
"variants": [
{ "format": "avif", "width": 1600, "quality": 0.82 },
{ "format": "webp", "width": 1200, "quality": 0.88 },
{ "format": "jpeg", "width": 800, "quality": 0.92, "icc": "profiles/display-p3.icc" }
],
"responsive": {
"breakpoints": ["(min-width: 1280px) 1200px", "(min-width: 768px) 65vw", "100vw"],
"density": ["1x", "2x"]
}
}
}
The esbuild plugin reads this file and schedules WASM work in parallel.
Implementing the esbuild Plugin
// build/plugins/image-pipeline.ts
import { Plugin } from 'esbuild'
import { readFile } from 'node:fs/promises'
import { runPipeline } from '../wasm/run-pipeline'
export const imagePipeline = (): Plugin => ({
name: 'image-pipeline',
setup(build) {
build.onStart(async () => {
const manifest = JSON.parse(await readFile('assets.manifest.json', 'utf-8'))
await runPipeline(manifest)
})
}
})
runPipeline
spins up Worker Threads, keeps concurrency under control, and handles failures gracefully.
// build/wasm/run-pipeline.ts
import { Worker } from 'node:worker_threads'
import os from 'node:os'
const workerCount = Math.max(1, Math.min(os.cpus().length - 1, 4))
export async function runPipeline(manifest: Record<string, any>) {
const entries = Object.entries(manifest)
await Promise.all(entries.map(([id, config]) => dispatchWorker(id, config)))
}
function dispatchWorker(id: string, config: any) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./workers/image-worker.ts', import.meta.url), {
workerData: { id, config }
})
worker.once('message', resolve)
worker.once('error', reject)
worker.once('exit', code => code !== 0 && reject(new Error(`worker ${id} exited ${code}`)))
})
}
Inside the worker, cache the WASM binaries to avoid repeated cold starts.
// build/wasm/workers/image-worker.ts
import { parentPort, workerData } from 'node:worker_threads'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const squoosh = require('@squoosh/lib')
const avif = require('@squoosh/avif')
(async () => {
const { id, config } = workerData
const { source, variants } = config
const jobs = variants.map(async variant => {
const image = await squoosh.ImagePool.fromPath(source)
await image.encode({
[variant.format]: {
quality: Math.round(variant.quality * 100),
effort: 7
}
})
await image.save(`public/images/${id}-${variant.width}.${variant.format}`)
})
await Promise.all(jobs)
parentPort?.postMessage({ id, ok: true })
})()
Auto-generate CSS with Lightning CSS
To keep CSS declarations in sync with the manifest, plug Lightning CSS into the pipeline.
// build/styles/picture-plugin.ts
import { readFileSync } from 'node:fs'
import { transform } from 'lightningcss'
import manifest from '../../asset-map.json'
export function injectImageSets(cssPath: string) {
const css = readFileSync(cssPath)
const result = transform({
filename: cssPath,
code: css,
drafts: { nesting: true },
visitor: {
Rule(rule) {
if (!rule.selectors.includes('.hero-visual')) return
const sources = manifest['hero-landing'].imageSet
rule.declarations.push({
kind: 'declaration',
property: 'background-image',
value: {
type: 'value',
value: `image-set(${sources.join(', ')})`
}
})
}
}
})
return result.code
}
After the build completes, Lightning CSS references asset-map.json
to emit image-set()
rules and align them with @media
blocks. Because the transformer itself runs on WASM, hot reload stays snappy.
CI Diffing and Signing
WASM builds are fast, but silent breakage is still a risk. Guard against it in CI.
- Visual diffs: Capture hero sections with Playwright and feed them to the Image Compare Slider CLI to compute ΔE2000.
- Metadata checks: Use ExifTool plus Advanced Converter to confirm ICC profiles and XMP survive the roundtrip.
- C2PA signing: Sign the PNG fallback via the
cai
CLI and append the signature URI toasset-map.json
.
# .github/workflows/build.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build:images
- run: npm run test:visual
- run: npm run sign:fallback
- uses: actions/upload-artifact@v4
with:
name: wasm-assets
path: public/images
Artifacts keep rebuild time down by 40% or more. When a hash mismatch appears, regenerate the derivatives and alert the team in Slack.
Edge Delivery Best Practices
- Cache keys: avoid scattershot
?format=
switches—preferAccept
negotiation. - Headers: default to
public, max-age=31536000, immutable
and addmust-revalidate
only to the C2PA-signed PNG fallback. - Fallbacks: if WASM conversion fails, return a high-quality JPEG rendered in Advanced Converter through a Lambda@Edge handler.
- Monitoring: collect
LargestContentfulPaint
viaPerformanceObserver
and watch for regressions when derivative sizes change.
Implementation Checklist
- [ ]
assets.manifest.json
declares every derivative rule. - [ ] WASM workers throttle concurrency to available CPU cores.
- [ ]
asset-map.json
records format, dimensions, and hashes. - [ ] Playwright + Image Compare Slider automate visual diffing.
- [ ] Advanced Converter handles ICC embedding and signing in the release flow.
- [ ] Accept negotiation and fallback logic are validated on the CDN.
Summary
A WASM-first build pipeline is easier to provision than native toolchains and delivers consistent results in cloud CI. By centering the flow on esbuild and Lightning CSS—and layering in Squoosh and WASM-based encoders—web engineers can finish image optimization during the build, keeping runtime costs down. Add automated validation and signing, and you get a pipeline that balances performance with trust, ready for global launches.
Related tools
Advanced Converter
Fine-grained AVIF/WebP conversion with color profile, subsampling, and speed.
Image Resizer
Fast in-browser resize. No upload.
Compare Slider
Intuitive before/after comparison.
Image Compressor
Batch compress with quality/max-width/format. ZIP export.
Related Articles
Loss-aware streaming throttling 2025 — AVIF/HEIC bandwidth control with quality SLOs
A field guide to balancing bandwidth throttling and quality SLOs when delivering high-compression formats like AVIF/HEIC. Includes streaming control patterns, monitoring, and rollback strategy.
Service Worker Image Prefetch Budgeting 2025 — Practicals for Priority Rules and Healthy INP
A design guide for numerically governing image prefetching in Service Workers so LCP improves without degrading INP or bandwidth. Covers Priority Hints, Background Sync, and Network Information API integration.
Edge Image Delivery Observability 2025 — SLO Design and Operations Playbook for Web Agencies
Details SLO design, measurement dashboards, and alert operations for observing image delivery quality across Edge CDNs and browsers, complete with Next.js and GraphQL implementation examples tailored to web production firms.
Image Delivery Optimization 2025 — Priority Hints / Preload / HTTP/2 Guide
Image delivery best practices that don't sacrifice LCP and CLS. Combine Priority Hints, Preload, HTTP/2, and proper format strategies to balance search traffic and user experience.
CDN Service Level Auditor 2025 — Evidence-Driven SLA Monitoring for Image Delivery
Audit architecture for proving image SLA compliance across multi-CDN deployments. Covers measurement strategy, evidence collection, and negotiation-ready reporting.
Core Web Vitals Practical Monitoring 2025 — SRE Checklist for Enterprise Projects
An SRE-oriented playbook that helps enterprise web production teams operationalize Core Web Vitals, covering SLO design, data collection, and incident response end to end.