Asset Router**. Instead of treating image processing as a linear sequence, the router evaluates three dimensions: sensitivity flag, batch volume, and target format. It then delegates to the appropriate execution boundary. Below is a production-grade TypeScript implementation that demonstrates this routing logic, WASM integration, and fallback handling.
Architecture Decisions
- Boundary Isolation: Local processing and remote processing are strictly separated. The router never mixes client-side and server-side operations in a single promise chain to prevent race conditions and memory leaks.
- Format Negotiation First: The router checks the target format before routing. If the target is AVIF or WebP and the asset is sensitive, it forces client-side WASM. If the source is HEIC/TIFF, it routes to conversion regardless of sensitivity.
- Main-Thread Mitigation: WASM operations are wrapped in a
Worker abstraction to prevent UI jank. Large assets (>25MB) are automatically downgraded to server-side processing to avoid browser memory limits.
- Metadata Stripping: EXIF and XMP data are removed at the routing layer, not the codec layer. This ensures privacy compliance regardless of which boundary executes the compression.
Implementation
// asset-router.ts
import type { ImageTask, ProcessingBoundary, RouteDecision } from './types';
export class MediaPipeline {
private readonly sensitivityThreshold = 25 * 1024 * 1024; // 25MB
private readonly batchLimit = 20;
private readonly conversionLimit = 100 * 1024 * 1024; // 100MB
constructor(
private wasmEngine: WasmImageProcessor,
private cloudBatch: BatchCompressor,
private cloudConverter: FormatTranslator,
private cloudResizer: DimensionScaler
) {}
public async route(task: ImageTask): Promise<RouteDecision> {
const decision = this.evaluateBoundary(task);
switch (decision.boundary) {
case 'local':
return this.executeLocal(task);
case 'batch':
return this.executeBatch(task);
case 'convert':
return this.executeConversion(task);
case 'resize':
return this.executeResize(task);
default:
throw new Error(`Unsupported boundary: ${decision.boundary}`);
}
}
private evaluateBoundary(task: ImageTask): RouteDecision {
const isLegacy = ['heic', 'tiff', 'raw', 'bmp'].includes(task.sourceFormat);
const isSensitive = task.requiresPrivacy;
const exceedsLocalMemory = task.fileSize > this.sensitivityThreshold;
const isBatch = task.batchCount > 1;
if (isLegacy) {
return { boundary: 'convert', reason: 'Legacy format requires server-side decoder' };
}
if (isSensitive && !exceedsLocalMemory) {
return { boundary: 'local', reason: 'Privacy constraint mandates client-side WASM' };
}
if (isBatch && task.batchCount <= this.batchLimit) {
return { boundary: 'batch', reason: 'Volume fits within free-tier batch limit' };
}
if (task.targetDimensions) {
return { boundary: 'resize', reason: 'Dimension manipulation requires resampling engine' };
}
return { boundary: 'local', reason: 'Default fallback to client-side compression' };
}
private async executeLocal(task: ImageTask): Promise<RouteDecision> {
const worker = new Worker(new URL('./wasm-worker.ts', import.meta.url));
worker.postMessage({ type: 'compress', payload: task });
return new Promise((resolve) => {
worker.onmessage = (e) => {
resolve({
boundary: 'local',
output: e.data.buffer,
metadata: { format: task.targetFormat, size: e.data.size }
});
};
});
}
private async executeBatch(task: ImageTask): Promise<RouteDecision> {
const payload = await this.cloudBatch.compress([task]);
return {
boundary: 'batch',
output: payload.zipBuffer,
metadata: { format: 'zip', count: payload.processedCount }
};
}
private async executeConversion(task: ImageTask): Promise<RouteDecision> {
if (task.fileSize > this.conversionLimit) {
throw new Error(`File exceeds ${this.conversionLimit / 1024 / 1024}MB conversion limit`);
}
const converted = await this.cloudConverter.transcode(task, task.targetFormat);
return {
boundary: 'convert',
output: converted.buffer,
metadata: { format: task.targetFormat, size: converted.size }
};
}
private async executeResize(task: ImageTask): Promise<RouteDecision> {
const scaled = await this.cloudResizer.scale(task, {
algorithm: task.contentHint === 'pixel-art' ? 'nearest-neighbor' : 'lanczos',
dimensions: task.targetDimensions!
});
return {
boundary: 'resize',
output: scaled.buffer,
metadata: { format: task.targetFormat, size: scaled.size }
};
}
}
Why This Architecture Works
The router eliminates decision fatigue by encoding operational constraints into deterministic logic. Sensitive assets never leave the browser because the evaluateBoundary method prioritizes the requiresPrivacy flag over batch efficiency. Legacy formats bypass client-side codecs entirely, routing to server-side translators that handle proprietary decoders. Batch operations are capped at twenty files to align with free-tier infrastructure limits, preventing silent failures when volume spikes.
The WASM execution path uses a dedicated worker thread to isolate memory allocation. This prevents the main thread from freezing during MozJPEG or AVIF encoding, which typically requires 2β4 seconds for 10MB assets. The resampling algorithm selection (nearest-neighbor vs lanczos) is tied to a contentHint property, ensuring pixel art retains hard edges while photographs preserve gradient smoothness.
Metadata stripping is intentionally decoupled from compression. By removing EXIF data before routing, the pipeline guarantees privacy compliance regardless of which boundary processes the asset. This is critical for product launches, internal documentation, and user-generated content where geolocation or camera serial numbers could leak.
Pitfall Guide
1. Ignoring Color Space Conversion
Explanation: Compressing images without normalizing to sRGB causes inconsistent rendering across devices. Display P3 and Adobe RGB assets appear desaturated or oversaturated when delivered without ICC profile stripping or conversion.
Fix: Inject a color space normalization step before compression. Use canvas.toBlob() with colorSpace: 'srgb' in WASM pipelines, or configure server-side processors to embed sRGB profiles.
2. Applying Lossy Compression to Transparency-Heavy Assets
Explanation: JPEG and MozJPEG discard alpha channels. Routing PNGs with transparency through lossy encoders produces black halos or flattened backgrounds.
Fix: Detect alpha presence via task.hasTransparency flag. Route transparent assets to OxiPNG, WebP, or AVIF. Never force JPEG conversion on assets requiring alpha preservation.
Explanation: Camera models, GPS coordinates, and editing software history embed in image headers. Server-side processors often preserve these by default, creating compliance risks under GDPR/CCPA.
Fix: Strip metadata at the routing layer, not the codec layer. Implement a pre-processing hook that removes all EXIF/XMP blocks before handing the buffer to compression engines.
4. Misaligning Resampling Filters with Content Type
Explanation: Using Lanczos interpolation on pixel art creates blur artifacts. Using nearest-neighbor on photographs produces jagged edges and moirΓ© patterns.
Fix: Pass a contentHint enum to the resizer. Map pixel-art β nearest-neighbor, photograph β lanczos, ui-element β bilinear. Document this mapping in your asset pipeline configuration.
Explanation: Delivering only AVIF or WebP breaks compatibility with Safari versions prior to 14.4 and older enterprise browsers. Single-format pipelines cause 404s or fallback to uncompressed JPEGs.
Fix: Implement <picture> element generation with srcset negotiation. Serve AVIF β WebP β JPEG fallback chains. Use server-side content negotiation headers (Accept: image/avif) when possible.
6. Blocking the Main Thread with Large WASM Operations
Explanation: Client-side compression of assets >25MB consumes significant heap memory and CPU cycles. Running this on the main thread freezes UI interactions and triggers browser watchdog timeouts.
Fix: Enforce a memory threshold in the router. Assets exceeding 25MB automatically route to server-side processing. Use OffscreenCanvas and Web Workers for all WASM operations.
7. Neglecting Server-Side Rate Limits on Free Tiers
Explanation: Free batch processors cap at twenty files per session and impose queue delays during peak hours. Automated pipelines that ignore these limits experience silent failures or throttled responses.
Fix: Implement exponential backoff with session tracking. Cache batch results locally. Fall back to client-side processing when queue depth exceeds acceptable latency thresholds.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single sensitive asset (product launch, internal doc) | Client-Side WASM | Zero network egress, auditable open-source codecs | $0 (browser compute) |
| Batch of 15 marketing images | Server-Side Batch | Fits within 20-file free tier, parallel processing | $0 (free tier) |
| HEIC/TIFF/RAW source files | Server-Side Conversion | Proprietary decoders unavailable in WASM | $0 (100MB limit) |
| Profile photo requiring exact 400Γ400 dimensions | Server-Side Resize | Lanczos/bilinear resampling engines optimized for scaling | $0 (free tier) |
| High-volume user uploads (>50 files) | Hybrid Routing + Queue | Bypasses session limits, falls back to local when queue deep | $0β$5/mo (infrastructure) |
| Legacy browser support required | Multi-format <picture> chain | Ensures graceful degradation without 404s | $0 (HTML generation) |
Configuration Template
// pipeline.config.ts
import { MediaPipeline } from './asset-router';
import { WasmImageProcessor } from './wasm-engine';
import { BatchCompressor } from './cloud-batch';
import { FormatTranslator } from './cloud-converter';
import { DimensionScaler } from './cloud-resizer';
export const createPipeline = (): MediaPipeline => {
const wasmEngine = new WasmImageProcessor({
codecs: ['avif', 'webp', 'mozjpeg', 'oxipng'],
workerPath: './wasm-worker.ts',
memoryLimit: 25 * 1024 * 1024
});
const cloudBatch = new BatchCompressor({
sessionLimit: 20,
retryPolicy: { maxAttempts: 3, backoff: 'exponential' }
});
const cloudConverter = new FormatTranslator({
maxSize: 100 * 1024 * 1024,
supportedSources: ['heic', 'tiff', 'raw', 'bmp']
});
const cloudResizer = new DimensionScaler({
algorithms: ['nearest-neighbor', 'bilinear', 'lanczos'],
maxDimensions: { width: 4096, height: 4096 }
});
return new MediaPipeline(wasmEngine, cloudBatch, cloudConverter, cloudResizer);
};
Quick Start Guide
- Initialize the pipeline: Import
createPipeline() and instantiate the router. No external dependencies required beyond standard browser APIs.
- Define asset tasks: Create
ImageTask objects with sourceFormat, targetFormat, fileSize, requiresPrivacy, and optional targetDimensions.
- Route and execute: Call
pipeline.route(task). The router automatically evaluates boundaries and returns a RouteDecision with the processed buffer.
- Integrate delivery: Pipe the output buffer to your CDN, storage bucket, or inline
<img> element. Generate <picture> fallbacks if targeting mixed browser environments.
- Monitor and adjust: Track main-thread blocking, batch queue depth, and format negotiation success rates. Tune the 25MB memory threshold and session limits based on your traffic profile.
This architecture transforms image optimization from a manual tool-selection exercise into a deterministic, privacy-aware pipeline. By routing assets to their optimal processing boundary, you eliminate unnecessary account creation, preserve sensitive data, and leverage modern codec efficiency without vendor lock-in. The result is a leaner payload, faster initial render, and a maintenance surface that scales with your team's actual requirements rather than third-party pricing tiers.