← Back to blog
Product August 11, 2026 by Javier Arancibia

99 MB to 71 KB: rewriting MiniStats in Machin

MiniStats is a real-time multi-machine metrics dashboard built with Bun/TypeScript. It works. The compiled binary is 99 MB because it bundles the entire Bun runtime. I rewrote it in Machin/MFL — a language that compiles through C. The binary is 71 KB. That's a 1400× reduction. Same features, same dashboard, same commands.

TinyStats dashboard — local machine and dk3 reporting real CPU, memory, and disk metricsLive smoke test: local machine + dk3 reporting real metrics through WebSocket. The 71 KB binary serves the dashboard, accepts metrics POSTs, and broadcasts to all connected dashboards.


A few months ago I built MiniStats — a real-time system metrics dashboard for multiple machines. The pitch was simple: Prometheus + Grafana takes an afternoon and 4 GB of RAM to show me what uptime already knows. MiniStats is a single binary that does the same thing in seconds — one server, N clients, a WebSocket, no database.

It works. I use it every day. But there was one thing that bugged me: the binary is 99 MB. That's because MiniStats is built with Bun, and Bun's --compile flag bundles the entire Bun runtime into the binary. The runtime is ~99 MB. My actual application logic is ~300 lines of TypeScript. The ratio of runtime to application code is absurd.

So I rewrote it in Machin — a language I've been building that compiles through C to native code. No runtime. No garbage collector. No bundled interpreter. Just C compiled with cc -O2. The result: 71 KB. A 1400× reduction.

The comparison

 MiniStats (Bun)TinyStats (Machin)
Binary size99 MB71 KB
Compressed25 MB (.xz)71 KB (already tiny)
Runtime bundledyes (Bun)no (pure C output)
Dependencieslibc, libmlibc, libm
Source lines~300 TypeScript~250 MFL
Setup timesecondsseconds

Same architecture: one server, N clients, WebSocket broadcast, no database. Same dashboard UI (IBM Plex Mono, scanline overlay, 2-column grid). Same commands (server, client, -v). The only difference is the binary size — and the fact that the Machin version has no runtime to bundle because there is no runtime.

Why is the Bun binary 99 MB?

Bun is a JavaScript runtime. When you compile a Bun app with bun build --compile, it embeds the entire runtime — the JavaScript engine, the WebSocket implementation, the HTTP server, the module system, the event loop, the garbage collector — into the binary. Your application code is a tiny fraction of the result. The runtime is the binary.

This is not a Bun problem. It's the same for Node.js (with pkg), Deno (with compile), Python (with PyInstaller), and Ruby (with Traveling Ruby). Any language with a runtime pays this cost when you want a single distributable binary. The runtime is ~100 MB. Your app is ~300 lines. You ship the runtime.

Why is the Machin binary 71 KB?

Machin compiles MFL to C, then C to native code. There is no runtime. No JavaScript engine. No garbage collector. No event loop. No module system. The binary contains only the actual program logic and the libc calls it needs. When the program calls listen(), it's a direct syscall. When it calls accept(), it's a direct syscall. When it parses a WebSocket frame, it's byte-level operations on a buffer — no abstraction layer, no runtime overhead.

The 71 KB breaks down roughly as: the HTTP server framework (~20 KB of C), the WebSocket RFC 6455 codec (~10 KB), the metrics collection and JSON serialization (~10 KB), the dashboard HTML string (~15 KB, inline), and the CLI dispatch (~5 KB). Plus the C runtime startup code and libc stubs. That's it. There's nothing else in the binary because there's nothing else to put in it.

The architecture: same app, different language

TinyStats architecture diagram

The architecture is deliberately identical to MiniStats:

  • Server — an HTTP server (machweb framework) that serves the dashboard HTML at /, accepts WebSocket connections at /ws, and accepts metrics POSTs at /api/report
  • Hub — a single goroutine that owns all state (dashboards map + clients map) via channels. Race-free by design — no mutexes, no locks, just one goroutine reading from channels
  • Client — collects free, df, df -i, uptime every 5 seconds and POSTs JSON to the server via http_request()
  • Dashboard — inline HTML/CSS/JS with vanilla WebSocket. No framework, no build step, no CDN. Just renders what the socket sends

The WebSocket implementation is pure MFL — the RFC 6455 frame codec parses and builds frames with byte-level operations (byte_at, bytes_concat, XOR unmasking). No libwebsockets, no Node.js ws package. Just the protocol, implemented from scratch, in ~160 lines of MFL.

The smoke test

I ran a live smoke test to prove it works end-to-end:

  1. Started the TinyStats server on my local machine (port 9094)
  2. Started a local client (tinystats client --name local --server http://localhost:9094)
  3. SSH-tunneled port 9094 through a remote VPS to expose it publicly
  4. Copied the 71 KB binary to the VPS and started a client there (tinystats client --name dk3 --server http://localhost:9094)
  5. Verified the dashboard shows both machines in real-time over WebSocket

Both machines report real metrics: my local machine (4.9 GiB RAM available, 95% disk, load 2.98) and the VPS (109 MiB RAM available, 16% disk, load 0.32). The WebSocket broadcasts the full state on every update. The dashboard renders it as a 2-column grid. No database, no persistence — just what's happening now. The screenshot above is from that live test.

What I learned about Machin

Building a full-stack web app (HTTP server + WebSocket + inline HTML/CSS/JS dashboard) in Machin surfaced a cluster of gotchas that I've recorded in the machin-learn knowledge base for future agents:

  • var hub Hub → needs var hub = Hub{} (package vars need an initializer)
  • map[K]V{} → must use make(map[K]V) (no composite map literals)
  • go func(){...}() → MFL's go takes a named function call, not a closure literal
  • int_str() / float_str() → the builtin is str() (handles int, float, bool)
  • index_of() → doesn't exist; use contains() + split()
  • serve(port, handler) → must wrap in serve(port, func(req){return handler(req)}) (bare fn name isn't first-class)
  • Channel-receive ok is bool, not int — use if !ok not if ok == 0

None of these are fundamental — they're all "the syntax looks like Go but MFL is stricter." The biggest surprise was go taking a named function call instead of a closure literal. If a goroutine needs captured state, you pass it through a channel or a struct field the named function reads. There are no closure-literal goroutines in MFL.

The takeaway

The 1400× size reduction is not an optimization. It's a consequence of the language model. Bun bundles a runtime because JavaScript needs one. Machin doesn't bundle a runtime because MFL doesn't need one. The binary is just the program, compiled to C, compiled to machine code. Nothing else.

This doesn't mean you should always use Machin instead of Bun. Bun has a richer ecosystem, better package management, and faster development for most web apps. But when you want a small, distributable binary — a CLI tool, a daemon, a utility that runs on a VPS with 512 MB RAM — the runtime tax matters. 99 MB for a metrics dashboard is absurd. 71 KB is not.

If you want to try it: github.com/javimosch/machin-tinystatscurl | bash, one server, N clients, done. The full source is ~250 lines of MFL across 4 files. The binary is 71 KB. The dashboard works.


TinyStats is open source at github.com/javimosch/machin-tinystats — download the 71 KB binary from the v1.0.0 release. Built with Machin/MFL. The original MiniStats is at github.com/javimosch/ministats.

Enjoyed this post?

Follow for more on agent-first engineering, self-hosted systems, and building for autonomy.

Follow @javimosch