bonsaicomp.netlify.app Β· Free to use Β· No uploads Β· No account required
Trim the size. Keep the quality.
Bonsai is a browser-based image compression tool built for professionals. It compresses JPEG, PNG, WebP, and AVIF images entirely client-side β nothing is uploaded to a server. Your files never leave your device.
Two modes:
- Smart Mode β drop images, pick a compression preset, download. Free, no sign-up.
- Pro Mode β per-image editor with live before/after preview, quality/blur/sharpen/resize controls, real-time size estimates, and format conversion.
All encoding happens inside a Web Worker (src/workers/compression.worker.js). The UI never blocks β users can continue editing other images while previous ones compress in parallel. Each image spawns its own worker instance which is terminated after the job completes to free memory.
const worker = new Worker(
new URL('../workers/compression.worker.js', import.meta.url),
{ type: 'module' }
)
worker.postMessage({ id, file, settings })
worker.onmessage = ({ data }) => { /* progress β done β error */ }Instead of guessing a quality level, Bonsai finds the highest quality that meets a target file size using iterative binary search (12 iterations, Β±0.6% accuracy). This is the same approach used by professional encoding tools.
async function binarySearchQuality(canvas, mime, targetBytes, startQ) {
let lo = 0.04, hi = Math.min(startQ, 0.99), best = null
for (let i = 0; i < 12; i++) {
const mid = (lo + hi) / 2
const blob = await canvas.convertToBlob({ type: mime, quality: mid })
blob.size <= targetBytes ? (best = blob, lo = mid) : (hi = mid)
if (hi - lo < 0.006) break
}
return best ?? canvas.convertToBlob({ type: mime, quality: 0.04 })
}Sharpening uses a proper Gaussian unsharp mask rather than a simple convolution. A blur pass is computed using weighted neighbourhood sampling, then the difference between the original and the blur is scaled and added back. This sharpens edges while leaving smooth gradients untouched β preventing the noise amplification that naive sharpening kernels cause.
// Gaussian kernel: centre Γ 0.36, cardinals Γ 0.12, diagonals Γ 0.04
const blurred =
d[i+c] * 0.36 +
(d[n+c] + d[s+c] + d[e+c] + d[w+c]) * 0.12 +
(d[ne+c] + d[nw+c] + d[se+c] + d[sw+c]) * 0.04
od[i+c] = clamp(orig[i+c] + strength * (orig[i+c] - blurred), 0, 255)EXIF, IPTC, XMP, and ICC profile data is removed by parsing the JPEG binary directly and stripping all APP1βAPP15 marker segments while preserving the image data, APP0 (JFIF header), quantisation tables, Huffman tables, and scan data. No external library β pure DataView manipulation.
If re-encoding a file (e.g. a well-compressed JPEG to WebP) produces a larger file than the original, Bonsai returns the original unchanged. Many tools silently inflate files β Bonsai never does.
Pro mode generates a live before/after preview as the user adjusts sliders. Instead of spawning a worker for each preview (too slow), it encodes a 640px thumbnail on the main thread using canvas.toBlob() and extrapolates the full-resolution size using the pixel-area ratio (blob.size / scaleΒ²). This runs in ~5β20ms, fast enough to feel instantaneous.
The preview function is debounced (320ms) and stored in a useRef so the debounce instance is stable across renders β a common React pitfall that causes the timer to reset on every render, making the preview never fire.
// Stable debounce β created once, never recreated
const generatePreview = useRef(
debounce(async (id, file, settings, prevUrlsMap) => {
const { url, estimatedSize } = await renderPreview(file, settings)
patchRef.current(id, { previewUrl: url, estimatedSize })
}, 320)
).currentCompressed files are stored as blobs in IndexedDB (not localStorage, which has a 5MB cap) so users can re-download past sessions. Metadata (timestamps, sizes, thumbnails) lives in localStorage for fast reads. Blobs are keyed by batchId:index and cleaned up on expiry β 72 hours for free users, 14 days for Pro.
When images are staged in Smart mode, Bonsai estimates the output size for all four presets simultaneously. It encodes each file at 320px at each preset's quality, extrapolates to full resolution, and sums across all files. This gives the user a concrete number β ~640 KB rather than High quality β before they've compressed anything.
Gate the download, not the UI. Free users have full access to the compression editor and see all results. The paywall only appears at the moment of download β after the user has experienced the product's value. Conversion happens at the right moment.
Side effects outside state updaters. React's state updater functions must be pure. Async side effects (triggering preview generation, spawning workers) are always called outside setState β a correctness requirement in concurrent React that most implementations get wrong.
Pointer Events API for the comparison slider. The before/after drag handle uses onPointerDown/Move/Up rather than separate mouse and touch handlers. One code path handles mouse, touch, and stylus uniformly.
src/
βββ workers/
β βββ compression.worker.js # All encoding β runs off main thread
βββ features/
β βββ tools/image/
β β βββ smart/ # SmartCompressor, PresetPicker, ResultsGrid
β β βββ pro/ # ProEditor, EditorControls, ProQueue
β βββ auth/ # AuthModal (email/password, forgot password)
β βββ history/ # HistoryPanel, BatchCard
βββ store/
β βββ useAuthStore.js # Supabase auth + plan state
β βββ useModeStore.js # Smart/Pro toggle + trial state
β βββ userHistoryStore.js # Batch history + IndexedDB sync
βββ lib/
β βββ supabase.js # Supabase client
β βββ historyDB.js # IndexedDB wrapper (saveBatchBlobs, getBatchBlobs)
βββ config/
β βββ presets.js # Single source of truth for compression presets
βββ styles/
βββ index.css # Sumi design system β CSS custom properties
supabase/
βββ functions/
βββ create-checkout-session/ # Stripe checkout (Deno, Edge Function)
βββ stripe-webhook/ # Plan sync on payment/cancellation (Deno)
| Layer | Technology |
|---|---|
| Frontend | React 18, Vite (Rolldown) |
| State | Zustand |
| Styling | Custom CSS design system (no framework) |
| Compression | Canvas API, OffscreenCanvas, Web Workers |
| Persistence | IndexedDB (blobs), localStorage (metadata) |
| Auth & DB | Supabase (PostgreSQL + Auth) |
| Payments | Stripe (subscriptions + webhooks) |
| Resend (SMTP) | |
| Backend | Supabase Edge Functions (Deno / TypeScript) |
| Hosting | Netlify (CI/CD from GitHub) |
git clone https://github.com/wh0isalfred/Bonsai
cd Bonsai
npm installCreate .env:
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
VITE_STRIPE_PRO_PRICE_ID=price_...
VITE_STRIPE_SUPPORTER_PRICE_ID=price_...npm run devSmart mode works without any environment variables. Auth and payments require Supabase and Stripe credentials respectively.
| Input | Output |
|---|---|
| JPEG | WebP, AVIF, JPEG, PNG, Original |
| PNG | WebP, AVIF, JPEG, PNG |
| WebP | WebP, AVIF, JPEG, PNG |
| AVIF | WebP, JPEG, PNG |
| GIF | WebP, JPEG |
| Free | Supporter ($1/mo) | Pro ($3/mo) | |
|---|---|---|---|
| Smart Mode | β up to 15 images | β unlimited | β unlimited |
| Pro Mode | β | β | β |
| History window | 72 hours | β | 14 days |
| Re-download | β | β | β |
MIT