On March 18, 2026, I launched a side project called ToolKnit — a collection of free browser-based tools that process everything locally. No uploads, no signup, no backend beyond a tiny PHP stats endpoint. Yesterday was day 100. This is what I learned about architecture, performance, and the limits of "just use the browser."
The Premise
Most online tool sites follow the same pattern: upload your file → server processes it → download the result. This means your sensitive documents (PDFs, photos, audio recordings) sit on someone else's server, even if only briefly.
ToolKnit's premise was simple: what if every tool ran entirely in the browser?
Not "mostly client-side with a fallback API." Not "client-side for small files, server for large ones." Entirely. Every PDF merge, every image resize, every audio conversion, every QR code generation — all happening in the user's browser via Web APIs, with zero bytes leaving their device.
100 days in, the site has 87 tools across 10 categories. Here's what worked, what didn't, and what I'd do differently.
The Stack: Boring on Purpose
HTML + Tailwind CSS + Vanilla JS
PHP (stats endpoint only)
scp for deployment
No React. No Vue. No build step beyond Tailwind compilation. No CI/CD pipeline. No Docker containers. No Kubernetes.
Why static HTML for 87 tool pages?
SEO is non-negotiable for a tool site. Static HTML means Google crawls everything immediately — no hydration delays, no client-side routing, no JavaScript-required indexing. Every page is a complete document with full Open Graph tags, Twitter Cards, and JSON-LD structured data.
Performance budget is tight. Users landing on a "compress PDF" page from Google Search expect the tool to work now, not after a 2 MB JS bundle downloads and hydrates. Most ToolKnit tools work without any framework — progressive enhancement is the default.
Server costs are near zero. The only PHP endpoints are
track.php(receives usage beacons) andpublic-stats.php(serves aggregated metrics). Everything else — PDF manipulation viapdf-lib.js, image processing via Canvas API, audio encoding via Web Audio API, video transcoding viaffmpeg.wasm— runs in the browser.
The tradeoff: Manual consistency across 87 HTML files. When you add a new footer link, you're editing 87 files (or writing a Python script to do it, then verifying each one). There's no component system to save you.
What Actually Works in the Browser
PDF Processing — Better Than Expected
pdf-lib.js handles PDF merge, split, compress, and page-to-image conversion entirely client-side. The only server-side fallback is pdf-to-word.php for extreme edge cases where client-side extraction fails — and even that strips identifying metadata before processing.
Surprise: PDF compression was the hardest. "Compress" sounds simple until you realize it means recompressing embedded images at lower JPEG quality, removing duplicate font subsets, and stripping unused objects — all without corrupting the file structure. The browser does this well, but the implementation is 400+ lines of careful stream manipulation.
Image Tools — Canvas API Is Underrated
17 image tools run on plain Canvas API:
- Resize, crop, grid split, format conversion (JPG/PNG/WebP), circle crop, silk-screen halftone effect, background removal (via color thresholding), HEIC conversion (via
heic2any) - The
imageSmoothingEnabled = falseflag is your best friend for pixel-perfect operations -
willReadRecently: trueongetContext('2d')eliminates console warnings when you callgetImageDatafrequently
Audio — Web Audio API Is Powerful but Quirky
A MIDI keyboard with 8 synthesized timbres (Grand Piano, Pipa, Guitar, Flute, Music Box, and more) — all generated in real-time through oscillator nodes, custom harmonics, and resonant filters.
Key learning: "Piano sound" isn't one oscillator. Each timbre required distinct synthesis architecture. The Pipa (琵琶) timbre needed sawtooth + square waves with non-integer harmonics (5.04×, 7.01× fundamental) to simulate the metallic twang of plucked silk strings, plus a noise burst layer for the finger-pick attack transient. Without that noise burst, it sounds like a cheap synth preset.
Video — ffmpeg.wasm Works, But...
ffmpeg.wasm enables browser-side video compression and format conversion. It works. But the ~32 MB one-time Worker download is a real cost on slow connections. I'm still evaluating whether this belongs in a "zero upload" toolkit or whether the download size undermines the value proposition.
What Doesn't Work in the Browser
Live Photo Frame — Killed After 48 Hours
The idea: frame iPhone Live Photos (HEIC + MOV pairs) and Android Motion Photos in elegant layouts, export to video.
Three fatal flaws:
Canvas
quadraticCurveTodraws parabolas, not circular arcs. At corner radii above ~60px, "rounded corners" morph into chamfered edges. The visual signature of the tool was mathematically impossible with the Canvas API I was using. (The fix isarc()with tangent calculations, but by then I'd lost confidence in the concept.)HEVC/H.265 videos from Android Motion Photos don't play natively in Chrome. ffmpeg.wasm transcoding at 1080×1920 was 30+ seconds for a 3-second clip. Users don't wait 30 seconds.
WeChat strips embedded video data from Motion Photos. The primary sharing pipeline — user receives Motion Photo via WeChat → opens in ToolKnit — was destroyed by app-level data stripping. The core use case didn't exist in the real world.
A tool that "almost works" is worse than no tool at all — it erodes trust. Ship what you're proud of, not what merely functions.
AI-Generated Pixel Art — Twice Bitten
First attempt: DeepSeek API to generate pixel art from text prompts. LLMs don't understand pixel-level constraints. Quality was garbage.
Second attempt: Local Canvas pixelation + gif.js encoding + color analysis. Solid engineering. The output just wasn't compelling. Pixel art demands either perfect algorithmic control or genuine artistic AI — we had neither.
Lesson: "It works" ≠ "it's worth shipping." I killed both versions.
The Bilingual Architecture — A Two-Layer Approach
After 60+ tools, I needed bilingual support (English + Chinese). The challenge: no framework, no build step, 87 static HTML files.
Solution: two-layer i18n without a framework.
| Layer | File | Scope |
|---|---|---|
| Shared UI | bilingual.js |
Nav, footer, breadcrumb, related tools, modals |
| Page-specific | [tool].js |
Hero, How It Works, Why Use, Pro Tips, FAQ |
| Homepage |
i18n.js + zh.json
|
Tool card titles, stats, search |
How it works:
// HTML: <span data-i18n="heroTitle">Compress PDF Files</span>
// JS: heroTitle: { en: 'Compress PDF Files', zh: '压缩 PDF 文件' }
function applyI18n(lang) {
document.querySelectorAll('[data-i18n]').forEach(function(el) {
var key = el.getAttribute('data-i18n');
var dict = I18N[lang];
if (dict && dict[key]) {
if (el.tagName === 'INPUT') el.placeholder = dict[key];
else if (el.tagName === 'OPTION') el.textContent = dict[key];
else el.innerHTML = dict[key];
}
});
}
window.switchLang(lang) triggers both layers simultaneously. Language preference persists via localStorage and syncs across pages.
Gotchas I hit:
-
data-i18non a parent with SVG children = disaster.innerHTMLassignment wipes the SVG. Always putdata-i18non a sibling<span>, never on a structural parent. -
Unescaped single quotes in i18n dictionaries silently break
window.switchLang. Onethere'swithout a backslash killed the entire language system. -
Batch
data-i18ninjection scripts silently fail when HTML entities (—) don't match Unicode characters (—). Always verify with a key checklist after migration.
The 100-Day Celebration — A Small Experiment
For day 100, I wanted to do something fun. I built a full-screen fireworks animation using Canvas API that would automatically appear on the homepage for 24 hours.
function launchFirework(x, y) {
var colors = ['#6366f1','#a855f7','#f59e0b','#ef4444','#22c55e','#3b82f6','#ec4899','#fff'];
var color = colors[Math.floor(Math.random() * colors.length)];
var count = 60 + Math.floor(Math.random() * 40);
for (var i = 0; i < count; i++) {
var angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.3;
var speed = 2 + Math.random() * 4;
particles.push({
x: x, y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1,
decay: 0.008 + Math.random() * 0.012,
color: color,
size: 1.5 + Math.random() * 2
});
}
}
The fireworks canvas uses clearRect (not a semi-transparent fill) so particles render crisply against the overlay. An IntersectionObserver starts/stops the animation when the changelog entry scrolls into view — no wasted CPU when off-screen.
Performance note: The entire celebration code — CSS, HTML, JS, fireworks — is inlined in index.html. After day 101, the IIFE returns immediately on the first line. Zero overhead for the remaining lifespan of the site.
I don't know how many people saw it. That's part of the fun.
The Desktop Client — Same Tools, Offline
Now I'm building a Windows desktop app using Tauri 2 + Rust. Same principle: all processing happens locally. The Rust backend handles PDF operations via lopdf (split/merge) and pdfium (render to images), while the UI reuses the Vanilla JS frontend.
The web version isn't going anywhere. The desktop client is for users who want a native app experience — drag-and-drop without a browser tab, offline-first, system-level file access.
Numbers After 100 Days
| Metric | Value |
|---|---|
| Tools shipped | 87 |
| Tools killed after shipping | 3 |
| Bilingual pages migrated | 19 of 87 |
| Blog guides published | 86 |
| Service Worker cache version | v144 |
| Total uses | 3,000+ |
| Server-side code | 2 PHP files (stats only) |
| Framework dependencies | 0 |
What I'd Tell Someone Starting a Similar Project
Static HTML scales further than you think. 87 pages with no framework is manageable if you're disciplined about shared CSS/JS and write verification scripts for batch changes.
The browser can do more than you expect. PDF manipulation, audio synthesis, video transcoding, image processing — all viable client-side. The main limitation isn't capability; it's download size for WASM libraries.
Kill features that "almost work." Live Photo Frame taught me that three near-fatal flaws compound, they don't cancel out. A tool that's 90% right erodes more trust than a missing tool page.
Inline celebration code and clean up after yourself. The fireworks animation was fun to build, but the real engineering was making sure it costs zero bytes of overhead after day 101.
data-i18non SVG parents will haunt you. Test language toggles on every page, every time.
What's Next
The remaining 68 legacy pages will migrate to the bilingual architecture gradually. The desktop client is in active development. New tools are still shipping — the 87th tool (Text Animation Maker with real MP4 export via WebCodecs) went live on June 23.
If you're curious about the full build history, the changelog is more honest than most — it includes the features that died, the bugs that took hours to find, and the occasional developer's journal entry that has nothing to do with code.
No signup. No uploads. No tracking beyond a usage counter. Just tools that work in your browser because that's where your files already are.
If you're building something client-side, I'd love to hear about your architecture decisions. What did you try that didn't work? What surprised you about the browser's capabilities? Drop a comment — I read all of them.
Top comments (0)