All posts

8 min read

Making the binary smaller, and the idea that made it bigger

Seven commits took the tuios release binary from 30.0 MB to 23.8 MB. A plan to leave screen saver data out of the binary made it 23.5 KB larger instead.

GGGaurav Gosain

On the morning of 25 September the tuios release binary for an Apple Silicon Mac was 29,971,890 bytes. Syntax highlighting had landed in the review diff earlier that morning, and its commit message said what it cost: 3.4 MB for the lexers. Thirty megabytes for a terminal multiplexer is a lot, and I wanted to know what was in there.

Seven commits later the same build was 23,797,538 bytes. The features stayed. What went was a hidden command nobody called and one pprof option, and tuios update now needs curl. Then I tried an idea that was supposed to cut more, and it made the binary bigger.

All sizes in this post are for the release build: CGO_ENABLED=0, -trimpath, -ldflags "-s -w", as in .goreleaser.yml. MB means a million bytes. The numbers are copied from the commit messages, which measured each change on top of the one before.

Target
23.80 MB (23,797,538 bytes) for darwin/arm64. Inside the CI budget of 24,600,000.
Release binary size in bytes before and after each cut, per target
commitdarwin/arm64 afterchangelinux/amd64 afterchange
before (dd7921ff)29,971,89031,559,840
642cbf3027,516,770-2,455,12029,094,048-2,465,792
e9a06c8127,380,754-136,01628,934,304-159,744
758103a526,792,738-588,01628,307,616-626,688
161c350824,352,290-2,440,44825,776,288-2,531,328
55273c9424,170,706-181,58425,583,776-192,512
835be99724,050,466-120,24025,510,048-73,728
45424d0623,797,538-252,92825,231,520-278,528
Stripped release builds, sizes from each commit's message, measured in the order shown. Turning a cut off subtracts its measured change; any other order is an estimate. The effect pack figure is the one measurement taken before the branch was deleted. MB here is a million bytes.

Where the bytes were

The Go linker keeps what is reachable from main and from every imported package's init, and drops the rest. It is good at dropping unused functions. It cannot drop a package-level table that an init function or a registry refers to, and it cannot drop a whole networking stack when one call path reaches into it.

So the question for each cut was the same: which import brings in this weight, and does the program need the import or only one small thing behind it? In every case below it was the second.

2.4 MB of lexers for 88 languages

The highlighting came from chroma. Its lexers package embeds all 279 of its XML lexer definitions, 2.4 MB, and parses the header of every one of them at start. A review shows the files in a repository, and most of those 279 languages never appear in one.

The review now keeps its own registry of 88 definitions, gzipped to 150 KB, and registers them on the first highlight rather than at start. The list covers the languages people keep in a repository, plus every lexer those delegate to. internal/diffview/lexers/gen.go regenerates it from the chroma version in go.mod, so an upgrade does not leave it behind.

Changing which lexers exist could change which lexer a file gets, so I checked it against chroma's full registry on 4,117 files: this repository, chroma's own test corpus and the Charm module cache. Every file whose language is carried gets the same lexer and identical tokens. The one difference was an improvement: go.mod files had been matched as AMPL, and are now plain.

That was the biggest single cut, 2,455,120 bytes, in 642cbf30.

Two copies of lipgloss

Three commands, agents, hosts and stash, still drew their tables with lipgloss v1. Every other table in tuios uses v2. So the binary carried both versions, with v1's table package, termenv and cellbuf next to them. Moving the three tables to v2 removed 136,016 bytes, and piped output is byte for byte what it was (e9a06c81).

A tailscale client for one call

tuios can find the other machines on your tailnet and offer them as hosts. To do that it linked tailscale's Go local API client and made one call, Status. That one call brought 627 KB of tailscale packages and their dependencies: cbor, a websocket library, an experimental JSON package, deephash, nacl.

The tailscale command already makes that call. tailscale status --json talks to tailscaled the same way the Go client does, including the variants of the macOS app. tuios now runs it, looks for it on PATH and then inside the macOS app bundle, and decodes the output into a small struct with the fields discovery reads. tuios hosts tailnet prints byte for byte what it printed before. The cut was 588,016 bytes on darwin (758103a5).

The larger effect came next. The tailscale client was the main reason net/http was in the binary at all.

net/http, for a flag and an update

With tailscale gone, two things still pulled in net/http, and with it crypto/tls, x509, HTTP/2 and mime.

The first was --pprof. It served net/http/pprof, the same way it did when I chased the resize lag. pprof needs very little from HTTP: one request per connection, a path and a few query parameters. cmd/tuios/pprof_server.go is a small HTTP/1.1 responder over runtime/pprof. It answers the index, cmdline, every named profile with debug= and gc=, the CPU profile and the trace. It does not do delta profiles (?seconds= on the heap), which needed net/http/pprof's profile merging; it answers 400 and says to diff two profiles instead. I checked it by hand with curl and go tool pprof, and the E2E soak, perf and daemon pprof tests read heap and goroutine profiles through it.

The second was tuios update, the only code in tuios that made an HTTPS request. It now fetches through curl. That was a decision, not a detail: it means tuios update depends on a program outside the binary. I kept it because curl is on every machine tuios installs on (the installer itself runs through it), and because it honours HTTPS_PROXY, NO_PROXY and the system certificate store the way net/http did. The GitHub token goes to curl in a config on stdin, never as an argument, so another user's ps cannot read it. If curl is missing, the command says so and names the releases page, so an update can still be done by hand.

That was 2,440,448 bytes on darwin (161c3508). The widget above will not let you take this cut without the tailscale one. Without it, net/http stays linked through tailscale's client and the two callers going away saves almost nothing.

Three smaller ones

Fonts. PNG screenshots fall back to Go Mono and Go Mono Bold. The gofont packages hold them as []byte literals, 352 KB. They are now embedded gzipped, 157 KB, and inflated once on the first PNG. The font bytes after inflating are identical, so screenshots do not change (55273c94).

Themes. bubbletint defines its 342 themes as *Tint literals with twenty *Color pointers each, and its default registry links all of them. tuios now fills the registry from a generated table, one string of RGB bytes plus names and credits, and builds the tints the first time it is filled. A test compares every built tint, field by field, with bubbletint's own, so a bubbletint update without regenerating the table fails the suite (835be997).

fang. fang styles the help and the errors of every command. It used golang.org/x/text/cases to upper-case the first word of a flag description, and those Unicode tables were 258 KB. It also carried a hidden tuios man command that linked a man page generator. tuios now carries a trimmed copy of fang under internal/fang with a small title() in place of x/text. A test compares the two on 220,000 random strings in several scripts. The --help of all 130 commands, and five error cases, are byte for byte what the previous binary printed. tuios man is the one visible change: it is now an unknown command. Nothing in the repository, the release config or the Nix package called it (45424d06).

What I did not cut

There were more candidates, and I said no to them: writing my own highlighter, trimming cobra, replacing gopsutil, putting the screen saver behind a build tag. Each was a riskier rewrite for a smaller saving. I also did not split tuios into more binaries. tuios-web is separate for security reasons, and that is the only reason good enough.

The idea that made it bigger

The screen saver has 36 effects from tuiffects. One of them, tuffbaby, is different from the rest: it plays a short clip of a photograph rendered in text, and the frames were about 18 KB of base64 in a Go source file. It was the one effect whose weight was data rather than code.

Data can be downloaded. So the plan was effect packs. In tuiffects the frames moved into a pack file, 13,508 bytes of deflated frames after a header line. By default the pack is embedded, and nothing changes. A program built with the tuiffects_nopacks tag leaves the file out; the effect stays listed, reports that it is not available, and works once the program hands it the file. The library checks the size and a SHA-256 digest compiled into it before any decoder reads the file, so the file can safely come from a download. On the tuios side, a build without packs downloaded the file on request.

The release build with all of that was 23.5 KB larger than without it.

When I went back to it for this post, the tuios side was gone, so I measured the half that is left: tuios at main with the tuiffects change swapped in, on Go 1.26.6 for darwin/arm64.

tuiffectsrelease binarychange
before packs23,851,666
packs, embedded (the default)23,885,170+33,504
packs, left out (tuiffects_nopacks)23,868,626+16,960

Even with the frames left out, the binary grew by 17 KB. Comparing the symbol tables of the two unstripped builds with go tool nm -size shows why. The string data dropped by 17,824 bytes: that is the base64 clip, gone as intended. The code that registers packs, looks them up, verifies them and loads them was added in its place, along with the metadata Go keeps for every function and type in it. Together they were bigger than the data they were written to remove. The 23.5 KB I measured at the time was for the whole feature, with the download code in tuios on top of this.

The measurement said to stop, so I stopped. The branches are deleted and all 36 effects stay built in. The lesson I took is not that on-demand data is a bad idea. It is that 18 KB is not worth moving. The seven cuts above each removed a dependency tree. This one tried to remove a leaf, and the machinery to remove it weighed more than the leaf.

Keeping it small

A size cut that nothing guards is a size cut until the next dependency. scripts/binary-size.sh builds tuios the way the release does for linux/amd64 and darwin/arm64, prints the sizes and fails over a budget, and .github/workflows/binary-size.yml runs it on every pull request and every push to main (7721bab0).

targetwhen the budget was set (Go 1.26.6)budget
linux/amd6425,182,37026,000,000
darwin/arm6423,834,59424,600,000

The budgets are about 3% above the size on the day they were set. That is room for ordinary growth, and not enough for a new dependency that brings a large tree with it. Undoing the lexer cut or the net/http cut alone puts the binary over. Raising a budget is meant to be its own commit, with a message that says what grew and why it is worth the bytes. The rules are in docs/perf.md under "Binary size budget".

The review highlighting that started all this still costs something. Its lexer definitions now take 150 KB instead of 2.4 MB, and the job will say so if that changes.