A client-side web application that converts images between formats using Rust compiled to WebAssembly. All processing happens in the browser — no server-side computation required.
┌─────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌───────────────┐ ┌──────────────────┐ │
│ │ JS Frontend │───▶│ Web Worker │ │
│ │ (Vite + TS) │◀───│ │ │
│ │ │ │ ┌────────────┐ │ │
│ │ - File input │ │ │ Rust WASM │ │ │
│ │ - Format │ │ │ Module │ │ │
│ │ selector │ │ │ │ │ │
│ │ - Preview │ │ │ image crate│ │ │
│ │ - Download │ │ └────────────┘ │ │
│ └───────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────┘
Frontend: TypeScript + Parcel (zero-config bundler with native WASM support)
Backend (WASM): Rust image crate compiled via wasm-pack
Threading: Web Worker to keep UI responsive during conversion
- The UI is simple (file picker, format selector, preview, download button) — a full Rust frontend framework (Yew/Leptos) would be overkill.
- Faster iteration on UI/styling with standard web tools.
- Clean separation: Rust handles compute-intensive image processing, JS handles the DOM.
The processing pipeline has constraints at multiple layers. The tightest bottleneck is WASM's 4 GB linear memory (wasm32) combined with the memory multiplier from image decoding.
Bottleneck chain (desktop):
| Layer | Limit |
|---|---|
| File API / ArrayBuffer | ~2.15 GB (Chrome), ~8 GB (Firefox) |
| WASM linear memory (wasm32) | 4 GB hard ceiling |
| Browser tab/process | 8-16 GB |
| Worker transfer (transferable) | No limit (zero-copy, O(1)) |
Mobile is much more constrained:
| Layer | Limit |
|---|---|
| WASM reliable allocation | ~300 MB |
| iOS Jetsam process limit | ~300-450 MB total (kills process silently, not catchable) |
Memory multiplier during conversion:
Total memory ≈ compressed input (~file size)
+ decoded RGBA pixels (W × H × 4 bytes)
+ working buffer (W × H × 4 bytes)
+ encoded output (~output file size)
+ WASM overhead (~20-50 MB)
Example: a 24 MP image (6000×4000) = 96 MB decoded RGBA → pipeline total ~230-320 MB.
V1 limits (conservative, reliable cross-browser):
- Max file size: 200 MB
- Max decoded image: ~100 megapixels (~400 MB RGBA)
- Pipeline total stays under ~1.6 GB, well within 4 GB WASM memory
The image crate can read dimensions from headers without full decoding, so we can validate both file size and pixel dimensions cheaply before attempting conversion.
Failure modes:
- Desktop: WASM
memory.grow()returns-1orArrayBufferthrowsRangeError— both catchable. - Mobile (iOS): Jetsam kills the process silently — not catchable. We must prevent this by enforcing limits upfront.
Future expansion paths (post-V1):
- Raise limit to 500 MB file size + ~375 MP dimension check
- Memory64 (wasm64): raises WASM ceiling to 16 GB, but 10-100% perf penalty and no Safari support yet
- Tiled/chunked processing: process image in strips without loading all pixels — removes the WASM memory bottleneck entirely
- Streaming file reads via
Blob.slice()to bypass ArrayBuffer limits
- Web Worker — All WASM conversion runs off the main thread to keep UI responsive.
- Transferable objects — Use
postMessage(result, [result.buffer])to transfer (not copy) byte arrays between Worker and main thread. Transfer is O(1) regardless of size. - Minimize JS↔WASM boundary crossings — Pass entire image buffer in one call, return result in one call. No per-pixel callbacks.
- Binary size — Only enable needed format features in the
imagecrate. Expected WASM binary: ~1-3 MB afterwasm-opt -O3. - SIMD — Optional future optimization. Compile with
-C target-feature=+simd128for supported browsers.
Areas that are fine for the MVP but would need refactoring as the app grows:
-
Vanilla TS → lightweight framework — Manual DOM state management works for the current simple UI (file picker, format selector, preview, download). If the app grows to include batch conversion, history, settings panels, or side-by-side comparison, consider migrating to a lightweight framework (Preact, Lit, or similar) to manage state and component lifecycle.
-
Single Worker → Worker pool — The current architecture processes one conversion at a time. Batch conversion would need either sequential processing in the single Worker (simple, slower) or a pool of Workers for parallel conversions (complex, faster). The current design doesn't prevent either approach.
-
Estimated → real progress reporting — The MVP uses a frontend-estimated progress bar (no Rust/Worker changes). For real progress, the Rust side would need to call back into JS mid-conversion via
wasm-bindgenclosures, with a structured Worker message protocol ({ type: "progress", percent: 45 }). Theimagecrate'sload_from_memoryandwrite_todon't expose progress callbacks natively, so this would require custom decoder/encoder wrappers — significant effort for marginal UX gain over the estimated approach. -
No cancellation — Once a conversion starts, it runs to completion. Cancellation options:
Worker.terminate()(heavy — destroys and recreates the Worker and WASM module) or cooperative cancellation in Rust via a shared flag (complex, requiresSharedArrayBuffer+ Atomics and COOP/COEP headers). Not needed for V1 but relevant for large images or batch processing.
| Format | Decode | Encode | Notes |
|---|---|---|---|
| PNG | Yes | Yes | Pure Rust. Reliable. |
| JPEG | Yes | Yes | Pure Rust. Reliable. |
| WebP | Yes | No | Pure Rust decoder. Encoder is lossless-only — skipped as output format for V1. |
| GIF | Yes | Yes | Pure Rust. Supports animation. |
| BMP | Yes | Yes | Built-in, simple format. |
| Format | Decode | Encode | Notes |
|---|---|---|---|
| TIFF | Yes | Yes | Pure Rust. |
| ICO | Yes | Yes | Built-in. |
| TGA | Yes | Yes | Built-in. |
| QOI | Yes | Yes | Fast lossless format. |
| SVG | Yes* | No | Requires resvg to rasterize, then encode to target. |
| Format | Why |
|---|---|
| AVIF | Encoding via ravif is extremely slow in WASM. Decoder (dav1d) requires C deps. Revisit when pure-Rust tooling matures. |
| Layer | Choice | Rationale |
|---|---|---|
| Image processing | image crate v0.25+ |
De facto Rust standard, pure-Rust codecs, WASM-compatible |
| Rust→WASM glue | wasm-bindgen |
JS↔Rust type marshalling |
| WASM build tool | wasm-pack |
Builds Rust to WASM, generates npm-compatible package |
| WASM optimizer | wasm-opt (Binaryen) |
20-30% smaller/faster binaries |
| Frontend bundler | Parcel | Zero-config, native WASM support, no plugins needed |
| Frontend language | TypeScript | Type safety for the JS layer |
| UI framework | Vanilla TS (no framework) | App is simple enough; avoids unnecessary dependencies |
| Styling | Tailwind CSS v4 | Utility-first, build-time processing via PostCSS, zero runtime overhead |
| Integration testing | Playwright | Headless browser tests for Worker↔WASM pipeline |
| Analytics | PostHog (JS SDK) | Event tracking, usage insights, deployed product analytics |
rust-image-converter/
├── Cargo.toml # Workspace root
├── crates/
│ └── image-converter/ # Rust WASM library
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # #[wasm_bindgen] exports
│ ├── convert.rs # Core conversion logic
│ └── formats.rs # Format detection & mapping
├── web/ # Frontend
│ ├── index.html
│ ├── package.json
│ ├── tsconfig.json
│ ├── .postcssrc # Tailwind PostCSS config
│ └── src/
│ ├── main.ts # Entry point
│ ├── worker.ts # Web Worker for WASM calls
│ ├── ui.ts # DOM manipulation / UI logic
│ └── styles.css
├── PLANNING.md # This file
└── .gitignore
The WASM module exposes a minimal API:
use wasm_bindgen::prelude::*;
/// Convert an image from one format to another.
/// `input` — raw bytes of the source image.
/// `target_format` — e.g. "png", "jpeg", "gif", "bmp".
/// Returns the converted image as raw bytes.
#[wasm_bindgen]
pub fn convert_image(input: &[u8], target_format: &str) -> Result<Vec<u8>, JsError> {
// ...
}
/// Detect the format of an image from its bytes.
/// Returns a string like "png", "jpeg", etc.
#[wasm_bindgen]
pub fn detect_format(input: &[u8]) -> Result<String, JsError> {
// ...
}
/// Get image dimensions without fully decoding.
#[wasm_bindgen]
pub fn get_dimensions(input: &[u8]) -> Result<JsValue, JsError> {
// Returns { width: u32, height: u32 }
}- User drops/selects an image file via
<input type="file">or drag-and-drop. - JS validates file size (reject if > 200 MB).
- JS reads the file as
ArrayBuffer→Uint8Array. - JS posts the bytes to a Web Worker.
- Worker calls
get_dimensions()— rejects if > 100 megapixels. - Worker calls
convert_image()with the target format. - Meanwhile on the main thread: estimated progress bar starts animating (see below).
- Worker posts the result bytes back (using transferable objects, zero-copy).
- Progress bar snaps to 100%.
- JS creates a
Blob→URL.createObjectURL()for preview and download.
Frontend-only — no Rust or Worker changes required.
Estimation model (calibrated from benchmark data):
estimated_ms = base_ms[format_pair] + (megapixels * ms_per_mp[format_pair])
Format-pair rates (initial estimates, refined from real benchmark data):
| Conversion | base_ms | ms_per_mp |
|---|---|---|
| JPEG → PNG | 20 | 40 |
| PNG → JPEG | 20 | 25 |
| WebP → PNG | 20 | 35 |
| BMP → JPEG | 20 | 25 |
| fallback | 30 | 50 |
Behaviour:
- On conversion start: calculate
estimated_ms, begin CSS transition on progress bar width from 0% → 90% overestimated_ms * 0.9. - The bar never reaches 100% on its own — it eases into ~90% and holds.
- On Worker response (success or error): immediately transition to 100%, then show result.
- If the Worker responds before the bar reaches 90%: snap to 100% (feels fast).
- If the Worker takes longer: the bar sits near 90% until the real result arrives (the "90% stall" — a well-known pattern users accept intuitively).
Implementation: ~30-40 lines of TS + a CSS transition. No complex state management needed.
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
image = { version = "0.25", default-features = false, features = [
"png", "jpeg", "gif", "webp", "bmp"
] }
[profile.release]
opt-level = "s" # Optimize for size
lto = true # Link-time optimization
strip = true # Strip debug infoCritical: default-features = false on the image crate — the defaults enable rayon (multithreading) which breaks single-threaded WASM.
# One-time setup
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
# Build WASM (from project root)
wasm-pack build crates/image-converter --target web --release
# Frontend dev (from web/ directory)
cd web && npm install && npx parcel src/index.html
# Production build
wasm-pack build crates/image-converter --target web --release
cd web && npx parcel build src/index.htmlRun with cargo test (native) and wasm-pack test --headless --chrome (WASM).
Test every supported input→output combination:
| Input↓ / Output→ | PNG | JPEG | GIF | BMP |
|---|---|---|---|---|
| PNG | - | T | T | T |
| JPEG | T | - | T | T |
| WebP | T | T | T | T |
| GIF | T | T | - | T |
| BMP | T | T | T | - |
Each cell (T) is a test that verifies:
- Output bytes are valid (can be decoded back)
- Output format matches the requested format
- Image dimensions are preserved
Test each conversion path against multiple image sizes:
| Category | Dimensions | Pixel Count | Expected RGBA Size | Purpose |
|---|---|---|---|---|
| Tiny | 1x1 | 1 | 4 B | Edge case — minimum valid image |
| Small | 100x100 | 10 K | 40 KB | Fast test baseline |
| Medium | 1920x1080 | ~2 MP | ~8 MB | Typical photo |
| Large | 4000x3000 | 12 MP | ~48 MB | High-res camera photo |
| Wide | 10000x100 | 1 MP | ~4 MB | Unusual aspect ratio |
| Tall | 100x10000 | 1 MP | ~4 MB | Unusual aspect ratio |
| Square max | 10000x10000 | 100 MP | ~400 MB | At the dimension limit |
Note: The "Square max" test should only run in CI or with a #[ignore] attribute due to memory requirements.
- Detect PNG, JPEG, WebP, GIF, BMP from valid file bytes
- Return appropriate error for unrecognized/corrupted bytes
- Detect format correctly even if file extension would be misleading (we work with bytes, not filenames)
- Correct dimensions for each supported format
- Correct dimensions for unusual aspect ratios
- Error on corrupted/truncated headers
| Test | Input | Expected |
|---|---|---|
| Empty input | &[] |
Error: meaningful message |
| Truncated file | First 100 bytes of a valid PNG | Error: decode failure |
| Random bytes | Random &[u8; 1024] |
Error: unrecognized format |
| Unsupported output format | Valid PNG, target = "avif" |
Error: unsupported format |
| Invalid format string | Valid PNG, target = "notaformat" |
Error: unsupported format |
Every conversion test records and reports elapsed time. Use std::time::Instant in native tests and web_sys::window().performance().now() in WASM tests.
Each test logs a structured line:
[PERF] PNG → JPEG | 1920x1080 | input: 2.4 MB | output: 0.8 MB | decode: 45 ms | encode: 32 ms | total: 77 ms
Timing breakdown per conversion:
| Metric | What it measures |
|---|---|
decode |
Time to decode input bytes into raw pixels |
encode |
Time to encode raw pixels into target format |
total |
Full convert_image() call (decode + encode + overhead) |
Benchmark matrix — Run the full format conversion matrix against each image size variant and record all timings. This produces a performance profile like:
| Size | PNG→JPEG | JPEG→PNG | WebP→PNG | BMP→JPEG | ... |
|---|---|---|---|---|---|
| 100x100 | 2 ms | 3 ms | 4 ms | 1 ms | |
| 1920x1080 | 77 ms | 120 ms | 95 ms | 65 ms | |
| 4000x3000 | 310 ms | 480 ms | 390 ms | 270 ms |
Implementation approach:
- Wrap each conversion in timing calls and print results to stdout
- Run benchmarks separately from correctness tests via a
#[cfg(feature = "bench")]feature flag or a dedicatedbenches/directory usingcriterion(for native) to get statistically stable measurements - For WASM benchmarks, use
wasm-pack testwithperformance.now()— these won't be as stable ascriterionbut give a realistic browser-context measurement
- PNG→PNG round-trip: pixel-perfect (lossless→lossless)
- BMP→PNG round-trip: pixel-perfect
- JPEG→PNG: dimensions preserved (lossy source, so no pixel-perfect check)
- Verify alpha channel is preserved where both formats support it (PNG→PNG, PNG→GIF)
Run with a headless browser test runner (Playwright).
These tests verify the full pipeline: file input → Worker → WASM → Worker → main thread → output.
| Test | What it verifies |
|---|---|
| WASM initializes in Worker | Worker can load and init the WASM module without errors |
| Worker responds to conversion message | Post a valid image buffer, receive converted bytes back |
| Worker returns errors correctly | Post invalid bytes, receive a structured error message (not a silent failure or crash) |
| Multiple sequential conversions | Convert 3 images in sequence — no memory leaks or stale state |
| Worker handles large transfers | Send and receive a ~50 MB buffer via transferable objects without timeout |
| Test | Steps | Assertion |
|---|---|---|
| File select → convert → download | Programmatically set file input, click convert, verify download blob | Blob is valid, correct MIME type, non-zero size |
| Format auto-detection | Load a JPEG, verify the UI displays "JPEG" as source format | Detected format string matches |
| Before/after metadata | Convert a known image, verify dimensions and file sizes display | Correct numbers rendered in DOM |
| Error display | Load a corrupted file, trigger convert | User-friendly error shown in UI, no console errors |
| Test | Steps | Assertion |
|---|---|---|
| File size limit | Attempt to load a >200 MB file | Rejected before reaching Worker, error shown in UI |
| Dimension limit | Load a valid image that exceeds 100 MP | Rejected after dimension check, error shown in UI |
Measure the full pipeline time from the frontend's perspective using performance.now():
| Metric | What it measures |
|---|---|
worker_init |
Time for Worker to load and initialize WASM module |
transfer_to_worker |
Time to post image bytes to Worker |
conversion |
Time the Worker spends on the WASM convert_image() call |
transfer_from_worker |
Time to receive result bytes back on main thread |
total_pipeline |
End-to-end from "user clicks convert" to "output blob ready" |
Each integration conversion test logs:
[PERF E2E] PNG → JPEG | 1920x1080 | worker_init: 120 ms | transfer_in: 1 ms | conversion: 77 ms | transfer_out: 0 ms | total: 198 ms
This reveals where time is actually spent — if worker_init dominates, we may want to pre-initialize the Worker on page load rather than on first conversion.
| Test | What it verifies |
|---|---|
| No main thread blocking | Start a conversion, verify UI remains responsive (e.g., a CSS animation doesn't freeze) |
| Blob URL cleanup | After download, verify URL.revokeObjectURL() was called (no memory leak) |
rust-image-converter/
├── crates/
│ └── image-converter/
│ ├── src/
│ └── tests/
│ ├── fixtures/ # Small test images (one per format)
│ │ ├── test.png
│ │ ├── test.jpg
│ │ ├── test.webp
│ │ ├── test.gif
│ │ └── test.bmp
│ ├── convert_test.rs # Format conversion matrix tests
│ ├── detect_test.rs # Format detection tests
│ └── dimensions_test.rs # Dimension reading tests
│ └── benches/
│ └── conversion_bench.rs # Criterion benchmarks (native) ✅
├── web/
│ └── tests/
│ ├── integration/
│ │ ├── worker.spec.ts # Worker lifecycle tests
│ │ ├── conversion.spec.ts # End-to-end conversion tests
│ │ └── validation.spec.ts # Validation guard tests
│ └── fixtures/ # Test images for frontend tests
Generate test images programmatically where possible (using the image crate in a build script or test helper) rather than checking in large binary files. Only check in a minimal set of real-world format samples for format detection tests.
# Rust unit tests (native)
cargo test --manifest-path crates/image-converter/Cargo.toml
# Rust WASM tests (headless browser)
wasm-pack test --headless --chrome crates/image-converter
# Rust benchmarks (native, via criterion)
cargo bench --manifest-path crates/image-converter/Cargo.toml
# Frontend integration tests (includes pipeline timing)
cd web && npx playwright testInitialize the PostHog JS SDK on page load. All events are fired from the frontend (main thread), never from the Worker.
Privacy: No image data, filenames, or file contents are ever sent to PostHog. Only metadata (format, size, dimensions, timings).
Fired once on page load after WASM Worker is initialized.
| Property | Type | Example |
|---|---|---|
wasm_init_ms |
number | 120 |
browser |
string | Auto-captured by PostHog |
device_type |
string | Auto-captured by PostHog |
Fired when the user selects or drops a file.
| Property | Type | Example |
|---|---|---|
source_format |
string | "jpeg" |
file_size_bytes |
number | 4200000 |
width |
number | 4000 |
height |
number | 3000 |
megapixels |
number | 12.0 |
input_method |
string | "drag_drop" or "file_picker" |
Fired when the user clicks Convert.
| Property | Type | Example |
|---|---|---|
source_format |
string | "jpeg" |
target_format |
string | "png" |
file_size_bytes |
number | 4200000 |
megapixels |
number | 12.0 |
Fired when the Worker returns a successful result.
| Property | Type | Example |
|---|---|---|
source_format |
string | "jpeg" |
target_format |
string | "png" |
input_size_bytes |
number | 4200000 |
output_size_bytes |
number | 15800000 |
size_change_pct |
number | 276.2 |
width |
number | 4000 |
height |
number | 3000 |
megapixels |
number | 12.0 |
conversion_ms |
number | 310 |
pipeline_total_ms |
number | 315 |
Fired when the Worker returns an error.
| Property | Type | Example |
|---|---|---|
source_format |
string | null | "jpeg" or null if detection failed |
target_format |
string | "png" |
file_size_bytes |
number | 4200000 |
error_type |
string | "decode_error", "encode_error", "unsupported_format" |
error_message |
string | "Failed to decode image" |
Fired when a file is rejected before conversion.
| Property | Type | Example |
|---|---|---|
reason |
string | "file_too_large" or "dimensions_too_large" |
file_size_bytes |
number | 250000000 |
megapixels |
number | null | 120.0 or null if rejected before dimension check |
Fired when the user clicks the download button.
| Property | Type | Example |
|---|---|---|
source_format |
string | "jpeg" |
target_format |
string | "png" |
output_size_bytes |
number | 15800000 |
User lands on page
→ app_loaded
User selects/drops a file
→ image_selected
→ (if rejected) validation_rejected [end]
User clicks Convert
→ conversion_started
→ conversion_completed OR conversion_failed
User clicks Download
→ download_clicked
- Use
posthog-jsSDK, initialized inmain.tswith the project API key - Store the PostHog API key in an environment variable (not hardcoded)
- Disable PostHog in development (
posthog.opt_out_capturing()) or use a separate dev project key - PostHog auto-captures pageviews, sessions, and device info — no need to track those manually
Since the frontend is vanilla TS (not a JS framework that renders everything client-side), all static content lives directly in index.html — fully crawlable by search engines without needing SSR or prerendering. The WASM/JS enhances the page with functionality, but the content is already in the HTML.
Primary (high volume, competitive):
- "image converter online"
- "convert image format online"
- "free image converter"
Long-tail (lower volume, higher intent, easier to rank):
- "convert png to jpeg online free"
- "webp to png converter"
- "convert bmp to png online"
- "jpeg to gif converter"
- "[format] to [format] converter" (every supported pair)
Long-tail format pairs to target (12 combinations from our supported formats):
| → | PNG | JPEG | GIF | BMP |
|---|---|---|---|---|
| PNG | - | png to jpeg | png to gif | png to bmp |
| JPEG | jpeg to png | - | jpeg to gif | jpeg to bmp |
| WebP | webp to png | webp to jpeg | webp to gif | webp to bmp |
| GIF | gif to png | gif to jpeg | - | gif to bmp |
| BMP | bmp to png | bmp to jpeg | bmp to gif | - |
<title>Free Image Converter — PNG, JPEG, WebP, GIF, BMP | Online & Private</title>
<meta name="description" content="Convert images between PNG, JPEG, WebP, GIF, and BMP instantly in your browser. No upload to any server — 100% private, free, and fast.">
<meta name="keywords" content="image converter, png to jpeg, webp to png, convert image online, free image converter">
<link rel="canonical" href="https://[domain]/">
<meta name="robots" content="index, follow"><meta property="og:title" content="Free Image Converter — PNG, JPEG, WebP, GIF, BMP">
<meta property="og:description" content="Convert images instantly in your browser. No uploads, 100% private.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://[domain]/">
<meta property="og:image" content="https://[domain]/og-image.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Free Image Converter — PNG, JPEG, WebP, GIF, BMP">
<meta name="twitter:description" content="Convert images instantly in your browser. No uploads, 100% private."><script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Image Converter",
"description": "Convert images between PNG, JPEG, WebP, GIF, and BMP formats. Runs entirely in your browser — no server uploads.",
"url": "https://[domain]/",
"applicationCategory": "UtilitiesApplication",
"operatingSystem": "Any (browser-based)",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"browserRequirements": "Requires WebAssembly support"
}
</script><script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "How do I convert a PNG to JPEG?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Drop your PNG file onto the converter, select JPEG as the output format, and click Convert. Your converted file downloads instantly — no server upload required."
}
},
{
"@type": "Question",
"name": "Is this image converter safe to use?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. All conversion happens locally in your browser using WebAssembly. Your images are never uploaded to any server."
}
},
{
"@type": "Question",
"name": "What image formats are supported?",
"acceptedAnswer": {
"@type": "Answer",
"text": "You can convert between PNG, JPEG, GIF, and BMP. WebP files can be converted to any of these formats."
}
},
{
"@type": "Question",
"name": "What is the maximum file size?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Files up to 200 MB are supported. Images can be up to 100 megapixels."
}
}
]
}
</script>The index.html should include visible, crawlable text (not injected by JS):
- H1: "Free Image Converter — Convert PNG, JPEG, WebP, GIF, BMP Online"
- Subheading: "100% private — your images never leave your browser"
- How It Works section (3 steps: drop file, pick format, download)
- Supported Formats section with brief descriptions of each format
- FAQ section (matches the FAQ schema above, rendered as visible
<details>/<summary>or similar) - Privacy note: Emphasise that processing is local — this is a genuine differentiator from competitors like CloudConvert/Convertio that upload to servers
Most competing image converters upload files to a server. Our client-side WASM approach is a genuine selling point for SEO copy:
- "No upload required" / "100% private"
- "Works offline" (if PWA is added later)
- "Your images never leave your device"
This messaging should be prominent in the title, meta description, H1, and page content. It's both a trust signal for users and a differentiator that search engines can surface in snippets.
Google uses Core Web Vitals as a ranking signal. Key targets:
| Metric | Target | How we achieve it |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | Static HTML content, small CSS, defer WASM loading |
| CLS (Cumulative Layout Shift) | < 0.1 | Fixed-size layout for tool area, no late-loading ads or banners |
| INP (Interaction to Next Paint) | < 200ms | All conversion in Web Worker, main thread stays free |
WASM load strategy: Load the WASM module lazily — don't block page render. Initialize the Worker in the background after the page is interactive. The tool UI is visible and the static content is readable immediately.
| File | Purpose |
|---|---|
robots.txt |
Allow all crawlers |
sitemap.xml |
Single-page for now, but needed for search console submission |
og-image.png |
Social sharing preview image (1200x630) |
favicon.ico + apple-touch-icon.png |
Branding in search results and bookmarks |
Post-MVP, create lightweight pages for high-value keyword pairs:
/png-to-jpeg— "Convert PNG to JPEG Online Free"/webp-to-png— "Convert WebP to PNG Online Free"- etc.
Each page is the same tool but with format-specific H1, meta tags, and pre-selected source/target formats via URL parameters. This is how competitors like CloudConvert rank for hundreds of long-tail keywords. Could be implemented as a single index.html that reads the URL path and adjusts the visible text + pre-selects the format dropdowns.
- File input (click to browse + drag-and-drop)
- Auto-detect source format and display it
- Target format selector (PNG, JPEG, GIF, BMP)
- Convert button
- Preview of converted image
- Download converted image
- Display image dimensions and file size (before/after)
- Estimated progress bar during conversion (see below)
- Error handling with user-friendly messages
- File size validation (200 MB limit)
- Pixel dimension validation (100 MP limit)
- Rust unit tests (conversion matrix, format detection, error cases)
- Integration tests (Worker lifecycle, end-to-end conversion, validation guards)
- PostHog analytics (7 events, env-based API key, disabled in dev)
- SEO: meta tags, Open Graph, JSON-LD (WebApplication + FAQ schemas)
- SEO: on-page content (H1, how it works, supported formats, FAQ, privacy note)
- SEO: robots.txt, sitemap.xml, favicon, og-image
- SEO: Core Web Vitals optimization (lazy WASM load, fixed layout, Worker offload)
- Batch conversion (multiple files)
- JPEG quality slider
- Image resize options
- Tier 2 format support (TIFF, ICO, TGA, QOI, SVG→raster)
- Side-by-side before/after comparison
- Paste from clipboard
- PWA support (offline usage)
- Dark mode
- Raise file size limit to 500 MB (with dimension guard)
- Real progress reporting from Rust (replace estimated bar with actual decode/encode progress)
- Format-specific landing pages (
/png-to-jpeg,/webp-to-png, etc.) for long-tail SEO
- Mobile-specific limits — Should we detect mobile and enforce a lower limit (~20 MB / ~15 MP), or let it fail gracefully?
- WebP lossy output — Revisit when a pure-Rust lossy WebP encoder becomes available, or investigate compiling
libwebpto WASM separately.