Taking minification offline: two Rust libraries, six platforms, seven languages

Moving my VS Code extension from a minification API to local Rust minifiers fixed every feature complaint I had — and shipped a package that only worked on my own laptop to a few thousand people who were not me.

  • vs code
  • extensions
  • rust
  • release engineering
  • packaging

When I published this extension in 2024 it was a thin client over Toptal's minification API. You right-clicked a file, the contents went over the network, minified text came back. It was small, it worked, and the whole thing rested on one assumption I'd flagged at the time: the minification happens on someone else's server.

Two years later that assumption came due — in three separate ways, over five months.

The cracks

The first crack was syntax, and it arrived early. #1 — the first issue the extension ever received — was two sentences long: CSS nth-child(# of .CLASS_name) doesn't compress properly. That's the modern An+B of S selector form, and the API handled it unreliably: sometimes the selector came back minified, sometimes the file came back essentially untouched.

I never fully fixed it from my side, because there was nothing on my side to fix. What I did instead is still sitting in the test file:

// CSS nth-child Test Suite - DISABLED due to inconsistent API behavior
// These tests fail inconsistently due to Toptal API not minifying nth-child selectors reliably
suite.skip('CSS nth-child Test Suite', function () {

Disabling a test because a third-party service is inconsistent feels pragmatic in the moment. A year later it reads as a note I wrote to myself and declined to act on.

That's the failure mode you can't work around from the client side: I could validate input, catch errors, show a better message — I could not make a remote minifier parse syntax it doesn't parse.

Underneath that, the structural problems had been there the whole time and were easy to ignore while things worked:

  • Network dependency. No connection, no minification.
  • Rate limiting. 30 requests per minute. My test suite was slow by design — it had sleep 20 steps and 30-second waits in CI purely to avoid tripping the limit.
  • A 5 MB ceiling. #3 is a user hitting it with one long JavaScript file and getting an HTTP 413 for their trouble — an error message about someone else's request limits, in an editor command.
  • Your code leaves your machine. For a minifier this is a strange thing to require, and it's a hard no in some workplaces.
  • Availability isn't mine. In February 2026 the CSS endpoint started timing out and answering 502s and 503s. #78 put it plainly: "Toptal API has changed. This extension does not work anymore." Nothing I could fix, nothing I could route around.

That last one forced the first move: in v1.2.0 I replaced the CSS half with clean-css running locally, and left JavaScript on the API. A hybrid architecture, which is a polite way of saying "half migrated".

A merged fix is not a released fix

Local CSS didn't end the syntax problem, it just moved it. Two months later #104 arrived with a precise version of the original complaint: @starting-style — a baseline CSS at-rule for entry animations — was being stripped during minification. Not mangled, removed. The reporter's dialogs stopped fading in and they'd gone back to an external tool for those files. "Which breaks my workflow" is exactly the sentence the extension exists to prevent.

This one was clean-css. I went looking for the bug and found it already had a fix: someone had contributed support, it had been reviewed, it had been merged — and it had never appeared in a release. The repository looked fixed. Every installed copy was not.

That's what turned a patch into an evaluation. If I was going to depend on a library to parse modern CSS correctly, "the fix exists on main" wasn't a good enough answer.

The assessment

I wrote the comparison into #108 before writing any code, mostly so I'd have to defend the choice to myself later:

Criteriaoxc-minify@swc/coreToptal API
Binary size (per platform)2.3 MB22 MB0 (remote)
Speed~60× terser~40× BabelNetwork dependent
Offline supportyesyesno
Rate limitsnonenone30 req/min
LanguageRustRust
Same ecosystem as LightningCSSyesnono

For CSS the equivalent decision landed on LightningCSS — also Rust, actively released, and correct on @starting-style, CSS Nesting and Color Level 5 rather than merged-but-unreleased on them.

So v1.3.0 shipped both halves local: LightningCSS for CSS, oxc-minify for JavaScript. No network, no rate limit, no file size cap, no code leaving the machine. The sleep steps came out of CI and the test suite got about a minute faster. The feature complaints went away.

And then I broke the extension for most of the people using it.

"Works on my machine" is a statement about packaging

Both libraries are Rust compiled to native .node bindings, distributed as optionalDependencies — one package per platform, and npm installs only the one matching the machine you're on. That's a good design. It also means the thing you package is machine-shaped.

I built the .vsix on my laptop, an Apple Silicon Mac, with vsce package and no --target. What went to the Marketplace contained exactly this:

extension/node_modules/lightningcss-darwin-arm64/lightningcss.darwin-arm64.node
extension/node_modules/@oxc-minify/binding-darwin-arm64/minify.darwin-arm64.node

Nothing for Linux. Nothing for Windows. Nothing for Intel Macs. A "universal" .vsix is served to every platform as a fallback, so every one of those users got a package with bindings for a CPU they don't have.

There was a second, independent bug stacked on top of it. lightningcss requires detect-libc unconditionally on Linux, to tell glibc from musl. It's a regular dependency of a library I'd externalised from the bundle, vsce doesn't reach it, and it never made it into the package. On macOS the dispatcher takes a different branch and never calls it — so the bug was invisible from where I was standing, and it meant that shipping per-platform builds alone would still have left Linux broken.

The way this surfaced to users is the part I keep coming back to. Both minifiers were required eagerly at the top of their modules, so the failure happened during activate() — before a single command was registered. VS Code's response to that is not an error dialog. It's "command not found", from every menu item and every keybinding, with no toast and nothing in the UI. To see the actual cause you had to open the Extension Host log.

3,750 installs, an unknown fraction of them silently broken, and no telemetry that would tell me which.

The thread that actually broke it open was one I had already closed. #145 reported the same "command not found" in June. I shipped v1.3.2 with an activation-events fix, closed the issue, and left a confident comment explaining what had been wrong. Six days later, from another user:

The bug is still present for me in version 1.3.2.

That one line is why the extension got fixed. Chasing it turned up two more independent causes of the identical symptom — extension.minify calling document.save() on an untitled document, which opens a blocking "Save As" dialog and looks exactly like a hung command; and a race where activate() awaited the l10n bundle before registering its commands, so a fast click landed in the gap. Commands are now registered synchronously at the top of activate(), and the platform bug turned up in the same pass. Four separate faults, one error message, three of them shipped as fixed at some point. On 26 July the same user came back with "now it works" — on a thread that had been closed for eighteen days.

Worse: it had already been reported before that. #118 came in months earlier from a Linux Mint user — "I had to downgrade to v1.1.0 to get it to work" — and it arrived with a diagnosis attached, pasted from an AI assistant: a PendingMigrationError about navigator becoming a global in Node. That was a plausible story about a real change in VS Code 1.101. I took it, bumped oxc-minify a version, shipped v1.3.1, and closed it. The symptom the user reported was accurate. The diagnosis was speculation, and I'd treated it as a finding. The real cause stayed in production for three more releases.

The fix, and the smoke test that should have existed

Two changes. First, detect-libc became a direct dependency and got explicitly whitelisted in .vscodeignore. Second, the release stopped happening on my laptop:

macos-15-intel · macos-latest · ubuntu-latest · ubuntu-24.04-arm · windows-latest · windows-11-arm

Six native GitHub Actions runners. On each one, npm ci naturally resolves the right bindings for that platform, vsce package --target <platform> produces a platform-tagged .vsix, and the Marketplace hands each user the artifact matching their OS and CPU.

The piece I'd been missing entirely is the last step. Before any artifact is uploaded, scripts/verify-vsix-activation.mjs extracts the packaged archive and does the dumbest possible check:

require('lightningcss').transform(/* … */);
require('oxc-minify').minifySync(/* … */);

If the thing that's about to be published can't load its own dependencies, the build fails.

My CI had been running tests on macOS, Ubuntu and Windows since the very first workflow in 2024. It never caught this, and it never could have: it tested the source tree, freshly npm ci'd on each runner, where the correct bindings are always present. The .vsix — the only artifact a user ever touches — was never opened by anything.

One more thing that week

The same pass turned up something unrelated and, in its own way, worse: the extension had been shipping translations for seven languages since v1.1.0, and six of them had never been displayed to a single user. That one has its own writeup — it's a different kind of mistake, and it deserves the space.

What I took from it

Test the artifact, not the source. Every platform bug I shipped lived in the gap between "the repository builds and passes on six runners" and "the file I uploaded works". A twelve-line script that unzips the package and requires its own dependencies would have caught a bug that reached thousands of installs.

Eager imports turn a dependency problem into a total outage. A missing binding should have been "minification failed, here's why". Because both libraries were required at module top level, it became "this extension has no commands" — silently, with the explanation buried in a log most users have never opened.

Someone else's diagnosis is a hypothesis. #118 arrived with a confident, well-written root cause that was wrong, and I shipped against it without reproducing anything. The reporter's symptoms were solid gold — silent failure, Linux, forced downgrade to v1.1.0, and that last detail alone pointed at packaging, since v1.1.0 predates the native bindings. Take the symptoms seriously; verify the story.

Closing an issue is a claim, not a fix. I closed #145 with a changelog entry and a paragraph explaining the root cause, and it stayed broken for the people who filed it. The comments that keep arriving on a closed thread are the only end-to-end test I have that runs on someone else's machine — treat "still broken in x.y.z" as the most valuable line in the tracker, not as noise on a resolved ticket.

Owning a dependency means owning its release cadence. Toptal's outage, clean-css's merged-but-unreleased fix, npm's optional-dependency behaviour — none of those were my code, and all of them were my bug. The move to local Rust minifiers didn't remove that exposure, it changed its shape: from "is the service up" to "did I package the right binary for your CPU".


The extension is on the Marketplace as JS & CSS Minifier Tool and Compressor — CSS and JS minified locally, offline, on every platform VS Code runs on, in seven languages that now actually display. The source is at miguelcolmenares/css-js-minifier.

If you use it, I'd genuinely like to hear where it falls short: a file that minifies badly, a message that reads wrong in your language, a platform that misbehaves. Open an issue with something that reproduces it, or send a pull request — the CI matrix handles the six-platform verification, so contributors don't need to own six machines to be confident in a change.