This article is published in English.
One Weather CLI, Five Toolchains: Rust, Go, Zig, Bun and Node.js
How the same small HTTP-plus-JSON CLI looks in Rust, Go, Zig, Bun and Node.js, and what binary size, build time and setup friction mean for your choice.
Micro-benchmarks such as a Fibonacci loop say very little about what it costs to ship a real command-line tool. A better test is a small utility that talks to the network, decodes JSON, does a bit of math and prints tidy output, built identically in several languages. This walkthrough follows exactly that experiment across Rust, Go, Zig, Bun and Node.js, so you can judge which toolchain fits your next CLI based on binary size, build time, runtime overhead and, most importantly, how much friction stands between an empty folder and a binary your users can run.
The headline results are worth stating up front. Final binaries ranged from 1.2 MB to 45 MB, and skipping compilation altogether means asking users to install a runtime of roughly 100 MB. Clean build times ranged from 0.8 seconds to 28 seconds. And the most pragmatic recommendation at the end is not the language with the fastest execution.
The test tool and why it is a fair workload
The utility is called wx. You give it a city name; it resolves that name to coordinates through the Open-Meteo geocoding API, requests current conditions from the Open-Meteo forecast API, and prints the result along with a computed "feels like" temperature. A typical invocation looks like this:
$ wx reykjavik
Reykjavik, Iceland
Temperature: 4.2C (feels like -0.4C)
Wind: 24 km/h NNW
Humidity: 68%
The tool is deliberately small, but it touches the four areas where CLI ergonomics actually differ between ecosystems:
- An HTTP client with TLS. Two HTTPS requests are needed, one for geocoding and one for the forecast.
- JSON decoding. Responses are mapped onto typed structures rather than poked at as loose objects.
- Real computation. The apparent temperature is a formula with branches, not string concatenation.
- Terminal output. ANSI colors and aligned columns, the part users actually see.
A language that cannot handle all four comfortably is not a good CLI choice, no matter how quickly it runs a tight numeric loop.
The one piece of shared logic
Every version implements the same three-branch rule. Below 10 °C it applies the Environment Canada wind chill formula. Above 27 °C it applies the NOAA Rothfusz heat index regression, expressed in Celsius with humidity as a percentage. In between, the raw temperature is returned unchanged. The TypeScript form, used by both the Bun and Node.js builds, is shown here; the other languages port the same arithmetic.
function feelsLike(tempC: number, windKmh: number, humidity: number): number {
if (tempC < 10) {
// Wind chill (Environment Canada formula)
const v = windKmh ** 0.16;
return 13.12 + 0.6215 * tempC - 11.37 * v + 0.3965 * tempC * v;
}
if (tempC > 27) {
// Heat index (NOAA Rothfusz regression, in Celsius)
const t = tempC;
const r = humidity;
return -8.784 + 1.611 * t + 2.338 * r - 0.146 * t * r
- 0.0123 * t * t - 0.0164 * r * r + 0.00221 * t * t * r
+ 0.000725 * t * r * r - 0.00000358 * t * t * r * r;
}
return tempC; // between 10C and 27C, raw temperature
}
Two details deserve attention. First, the regime boundaries are the part a sloppy port gets wrong, so they make a good correctness check when you compare implementations. Second, both formulas have validity ranges that this simple version ignores: the wind chill equation is intended for wind speeds of roughly 5 km/h and above, and the Rothfusz regression is only meant for hot, fairly humid conditions. For a toy weather tool that is acceptable, but a production version should clamp or fall back to the raw temperature outside those ranges.
How each implementation felt to build
The complete code across all five languages runs to about 360 lines, so only the revealing fragments are shown. What matters is where each ecosystem helped and where it got in the way.
Rust with reqwest, serde and a derive-based argument parser
The Rust build uses a popular derive-based crate for argument parsing, reqwest for HTTP and serde for deserialization. The fragment below shows the pattern that makes Rust pleasant for this kind of work: derive macros generate both the CLI parser and the JSON decoders from plain struct definitions, and the field types are checked at compile time.
#[derive(Parser)]
#[command(name = "wx", about = "Weather lookup")]
struct Cli {
city: String,
}
#[derive(Deserialize)]
struct WeatherResponse {
current: CurrentWeather,
}
#[derive(Deserialize)]
struct CurrentWeather {
temperature_2m: f64,
wind_speed_10m: f64,
relative_humidity_2m: u8,
wind_direction_10m: f64,
}
The whole program came to about 95 lines covering both API calls and the temperature logic. The async runtime, tokio, is something you opt into explicitly, whereas Node.js hides its event loop from you. That explicitness is useful for control but contributes to binary size.
The surprising part was the default output: a plain cargo build --release produced an 8.4 MB binary. Enabling link-time optimization and symbol stripping in Cargo.toml brought it to 3.8 MB. Those settings are well known among people who ship Rust tools, but a newcomer would likely distribute the larger file without realizing a smaller one was two lines away.
Go with nothing but the standard library
The Go build needs no third-party packages at all. net/http, encoding/json and os.Args cover the entire job. The fragment shows the response struct, where tags map JSON keys to idiomatic Go field names, and the start of main with a minimal usage check.
type WeatherResponse struct {
Current struct {
Temperature float64 `json:"temperature_2m"`
WindSpeed float64 `json:"wind_speed_10m"`
Humidity int `json:"relative_humidity_2m"`
WindDir float64 `json:"wind_direction_10m"`
} `json:"current"`
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: wx <city>")
os.Exit(1)
}
city := os.Args[1]
// geocode, fetch weather, compute, print
}
The finished program is 68 lines. One pattern dominates it: the if err != nil { log.Fatal(err) } check appears five times, once each for the geocoding request, its body, its decoding, the forecast request and the forecast body. That repetition is not a real problem, but it is the most common line in practically any Go CLI.
A working binary existed in roughly ten minutes. The speed did not come from Go being a minimal language (it has strong opinions that not everyone enjoys) but from having nothing to decide: no library comparison, no dependency download, no configuration.
Zig 0.16 with std.http.Client and std.json
Zig, tested at version 0.16, was the most educational build and also the slowest to finish. The fragment shows the response struct and the start of main, where a debug allocator is created and then passed around. That is the defining trait of Zig: any function that may allocate heap memory receives an allocator argument, so memory ownership is always visible in the call signature.
const WeatherResponse = struct {
current: struct {
temperature_2m: f64,
wind_speed_10m: f64,
relative_humidity_2m: u8,
wind_direction_10m: f64,
},
};
pub fn main() !void {
var debug_allocator = std.heap.DebugAllocator(.{}){};
defer _ = debug_allocator.deinit();
const allocator = debug_allocator.allocator();
// Every function that might allocate takes `allocator` as a parameter.
// This is Zig's deal: you control memory, always.
}
The program grew to 108 lines, making it the longest of the five. Compilation itself took just 0.8 seconds, the quickest result in the comparison, yet the build took over an hour of wall-clock effort. The cause was TLS. std.http.Client includes its own TLS implementation but still needs trusted root certificates, and on the test machine it could not find the system CA bundle. The only symptom was error.TlsInitializationFailed with no additional context. The fix, loading the certificates explicitly through std.crypto.Certificate.Bundle and passing that bundle to the client, surfaced only in a GitHub issue discussion. Your results may differ by platform, but the lesson holds: a fast compiler cannot recover time lost to opaque runtime errors.
Explicit allocator passing is great for software where memory behavior matters. For a utility that allocates a few strings and one JSON buffer, it mostly adds ceremony. Zig does have an integrated package manager based on build.zig.zon, available since 0.11, but the ecosystem is still sparse; no maintained terminal color library turned up, so a roughly 40-line ANSI helper was copied from a gist instead.
Bun with a single TypeScript file
The Bun version is the shortest and the easiest to read. It reads the city from Bun.argv, exits with a usage message if it is missing, then uses the built-in fetch twice and decodes each response with .json(). Top-level await means there is no wrapper function.
const city = Bun.argv[2];
if (!city) {
console.error("usage: wx <city>");
process.exit(1);
}
// Geocode city name to coordinates
const geoRes = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
);
const geo = await geoRes.json();
const { latitude, longitude } = geo.results[0];
// Fetch weather
const wxRes = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,wind_speed_10m,relative_humidity_2m,wind_direction_10m`
);
const data = await wxRes.json();
The full file is 47 lines including both requests, and it was running in about eight minutes, most of which went into the temperature formula. Notice what the snippet does not do, though: it never checks res.ok, and geo.results[0] is undefined when the geocoder finds no match, so a misspelled city crashes with a destructuring error instead of a friendly message. The brevity is real, but a production CLI needs those few extra lines.
The catch with Bun is distribution. bun build --compile creates a standalone executable of about 45 MB for this tiny tool, because the output bundles the JavaScriptCore engine and the Bun runtime. That is about seven times the Go binary and close to forty times the Zig one. If your users already have Bun installed, running the .ts file directly avoids the issue entirely.
Node.js, which needs no separate listing
The Node.js implementation is essentially the Bun code, since Node.js has shipped a native fetch since version 18. The meaningful differences sit in the runtime:
- Startup cost. Most of the gap in total run time comes from process startup itself: 82 ms for Node.js versus 24 ms for Bun, while the mocked network round trip adds only a few milliseconds. For one interactive command nobody notices; inside a shell loop that runs the tool hundreds of times, it adds up.
- Distribution. Users either need the Node.js runtime, about 100 MB installed, or you produce a standalone file through Single Executable Applications. SEA is first-party, but its stability status has been evolving, so check the current documentation for your Node.js version. Because it embeds the whole runtime, the result lands in the same size class as a compiled Bun binary.
- TypeScript. Node.js can now strip erasable TypeScript syntax on its own, so a plain
.tsfile runs withouttsxorts-node; the feature was described as stable on the 24 LTS line at the time of writing. Only erasable syntax is supported: annotations disappear, but constructs that emit JavaScript, such as enums, namespaces and parameter properties, still require a transpiler. Bun still goes further without configuration, handling JSX, decorators and path aliases. For a deeper look at what native stripping covers, see what Node.js native TypeScript support actually does and doesn't do.
Seen purely from the angle of a brand-new CLI, Node.js offers the same code as Bun with slower startup and a heavier distribution story. That is a narrow verdict, though. If your team already standardizes on Node.js, relies on its npm compatibility guarantees, or maintains an existing CLI on it, those factors can easily outweigh a 60 ms startup difference.
Measurement setup and the numbers that were reported
Timings were collected on an M3 MacBook Pro with 18 GB of RAM running macOS 26, using hyperfine with hyperfine --warmup 3 --min-runs 50 './wx reykjavik'. To remove network noise, both API calls pointed at a local mock HTTP server returning fixed JSON. The full-run figure is wall-clock time from process start to exit, including startup. Development times are rough estimates rather than measured values, so compare the ratios, not the minutes.
The key figures from the run:
- Rust: about 95 lines, 3.8 MB after tuning (8.4 MB by default), 28 seconds for a clean build, 4.8 ms execution.
- Go: 68 lines, 6.2 MB, under one second to compile, 5.2 ms execution, working in about 10 minutes.
- Zig: 108 lines, 1.2 MB, 0.8 seconds to compile, over an hour of total effort.
- Bun: 47 lines, about 45 MB compiled, 24 ms full run, working in about 8 minutes.
- Node.js: same code as Bun, 82 ms full run, roughly 100 MB runtime or an SEA binary in Bun's size class.
The size figures depend on build flags. Rust needed lto = true and strip = true under [profile.release]. Go was built with go build -ldflags='-s -w' to drop debug information. Zig's 1.2 MB is a ReleaseSmall build; it stays tiny even with TLS because the HTTP client and crypto code live in the standard library and are statically linked with dead code removed, rather than pulling in something like OpenSSL. A ReleaseSafe build, which keeps runtime safety checks, came out around 2.1 MB.
What the benchmark numbers hide
Zig wins on paper and loses on the clock
By the metrics, Zig produced the smallest and one of the fastest binaries. The hour spent on 108 lines tells a different story:
- The certificate bundle problem consumed more than 40 minutes behind a one-line error.
- The
ReleaseSmallbuild that yields 1.2 MB has no stack traces;ReleaseSafekeeps panic traces but is the 2.1 MB variant. - Allocators had to be threaded through every string operation, and two forgotten
deferfrees produced leaks that surfaced only when the debug allocator reported them at exit. - A terminal color helper had to be copied in because the ecosystem lacked one.
For a long-lived tool where you want to own every allocation and every byte of output, that explicitness pays off over time. For something you want working by lunch, it is currently a poor fit. The language design is elegant; the surrounding ecosystem is simply younger.
Go is never the best at one thing and still wins overall
Go compiles in under a second, yields a reasonable self-contained binary, needs only the standard library, and was working in ten minutes. It is larger than tuned Rust (6.2 MB versus 3.8 MB) and marginally slower (5.2 ms versus 4.8 ms), but a person cannot perceive that difference in a tool that exits in single-digit milliseconds.
Cross-compiling is a single environment variable, for example GOOS=linux go build. Rust gets close with cargo build --target x86_64-unknown-linux-gnu or the cargo-zigbuild helper, and Zig arguably has the strongest cross-compilation story because it bundles its own linker and libc. The difference is that Go asks for no extra tools and no setup. That is the recurring pattern: Go rarely tops any single metric, but it has the least total friction.
Bun is excellent locally and awkward to ship
Bun delivered the cleanest code in the least time. For a personal script that lives in ~/bin as a .ts file, it is hard to beat. For distribution, 45 MB for a weather lookup is a real drawback, and since almost all of it is the embedded engine, there is no practical way to shrink it without a slimmer runtime build from the Bun team, which did not exist at the time of testing.
Rust's pitch for small tools has weakened
A few years ago the case for Rust CLIs rested on speed, safety and small binaries. For I/O-bound tools, that case is now less clear-cut:
- Go matches Rust's practical speed.
- Zig produces smaller binaries without any tuning.
- A 28-second clean build for a 95-line program is a heavy tax on quick utilities.
Rust still shines where a tool attracts thousands of users and years of maintenance, and the type system keeps catching edge cases along the way. ripgrep, fd, bat, delta and hyperfine are all Rust CLIs, and all are maintained seriously over long periods rather than written in a weekend. For quick projects the overhead rarely pays back; for a widely distributed tool it may be the option least likely to accumulate subtle bugs.
Choosing a toolchain for your next CLI
The decision depends less on raw speed than on who will run the tool and how it reaches them:
- Default to Go when the goal is the lowest total cost from empty directory to distributed binary: working code in minutes, sub-second builds, a single 6.2 MB file with no dependencies and effortless cross-compilation.
- Pick Rust for tools with large audiences where margins matter, such as build tools, linters and test runners, and accept the compile times.
- Use Bun for personal or team-internal tools where everyone already has the runtime and the file never needs compiling.
- Hold off on Zig for casual CLIs until its ecosystem and error messages mature; the 1.2 MB binary is impressive, but the effort to get there is not yet proportionate for simple utilities.
- Keep Node.js where it is already your platform. For a brand-new standalone CLI, it offers little that Bun does not, though ecosystem and organizational constraints may still make it the sensible choice. For a broader runtime comparison, see Node.js, Deno and Bun compared.
Every implementation is small enough to rebuild from the fragments above in an afternoon, together with a mock server and a hyperfine script. Your absolute numbers will vary with hardware, operating system and toolchain versions, but the relative picture should be stable: Zig smallest, Bun largest, Go the least friction.
Key takeaways
- Measure the whole path to a shipped binary, not just execution time; setup, debugging and distribution dominate for small tools.
- Default build settings can double binary size, so learn the release flags of whatever toolchain you choose.
- Runtime-based binaries from Bun or Node.js SEA carry the entire engine, which matters far more than their startup time.
- Validate inputs and HTTP responses even in short scripts; the shortest implementation is often the one missing error handling.
- Treat specific version statuses and sizes here as a snapshot and re-check them against current releases before deciding.