DEV Community

Cover image for 100 Days, 87 Tools, Zero Servers: What I Learned Building a Fully Client-Side Utility Suite
Zihang Dong 董子航
Zihang Dong 董子航

Posted on

100 Days, 87 Tools, Zero Servers: What I Learned Building a Fully Client-Side Utility Suite

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
Enter fullscreen mode Exit fullscreen mode

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?

  1. 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.

  2. 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.

  3. Server costs are near zero. The only PHP endpoints are track.php (receives usage beacons) and public-stats.php (serves aggregated metrics). Everything else — PDF manipulation via pdf-lib.js, image processing via Canvas API, audio encoding via Web Audio API, video transcoding via ffmpeg.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 = false flag is your best friend for pixel-perfect operations
  • willReadRecently: true on getContext('2d') eliminates console warnings when you call getImageData frequently

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:

  1. Canvas quadraticCurveTo draws 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 is arc() with tangent calculations, but by then I'd lost confidence in the concept.)

  2. 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.

  3. 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];
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

window.switchLang(lang) triggers both layers simultaneously. Language preference persists via localStorage and syncs across pages.

Gotchas I hit:

  • data-i18n on a parent with SVG children = disaster. innerHTML assignment wipes the SVG. Always put data-i18n on a sibling <span>, never on a structural parent.
  • Unescaped single quotes in i18n dictionaries silently break window.switchLang. One there's without a backslash killed the entire language system.
  • Batch data-i18n injection scripts silently fail when HTML entities (&mdash;) 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
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. data-i18n on 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)