Learn Zig as a JavaScript developer: build a CLI and an HTTP server, compile static binaries, and measure size and memory against Node.js.
By FELFAL Bouabid · October 15, 2026
Node.js is the default tool for small network services and command-line tools, and for good reason: the ecosystem is enormous and shipping is fast. Zig offers a different trade. You compile to a single static binary with no runtime, no node_modules and no install step on the target machine, and you pay for it with manual memory management and a young ecosystem.
This article is for JavaScript developers who want to see what that trade looks like in practice. You will build two real programs, a word-count command-line tool and a small JSON HTTP server, learn the Zig ideas that map to what you already know, and get scripts to measure binary size, memory and start-up time on your own machine. Everything here was compiled and run with Zig 0.17.0.
Two honest notes about the title. 'Replacing Node.js' means replacing it for small, narrow services and tools, not for a Next.js app with an ORM and forty dependencies. And '2MB' is a ceiling, not a promise: the programs below build to well under 1 MB with ReleaseSmall, but the number grows with whatever you link in. Section 5 shows how to measure yours.
1. Why look at Zig at all
- One file, no runtime. The output is a static executable you can copy to a server that has nothing installed.
- Cross-compilation from one machine. The same compiler builds for Linux, Windows and macOS, on x86-64 and ARM, by changing one flag.
- Small footprint. There is no garbage collector and no interpreter, so memory use at idle is a fraction of a typical Node process.
- C interop. You can call C libraries directly and even use Zig as the C compiler in an existing build.
What you give up is covered in section 7. Read it before you plan a rewrite.
2. A JavaScript-to-Zig cheat sheet
- const and let become const and var. Zig also refuses to compile unused variables and silent type conversions.
- null and undefined become optionals. A value that might be missing has a type like ?u32. You unwrap it with orelse or with an if that captures the value.
- try and catch become error unions. A function that can fail returns !T. Errors are ordinary values: you write try to propagate one and catch to handle one. There are no exceptions.
- Garbage collection becomes allocators. Anything on the heap is allocated from an allocator you pass in, and you free it yourself. Since the new main signature, the runtime hands you a general-purpose allocator and an arena that frees everything at exit.
- finally becomes defer. A defer statement runs when the scope ends, which is how you close files and sockets.
- Classes become structs. Structs hold data and methods. There is no inheritance.
- Generics and TypeScript types become comptime. Code can run at compile time, and types are values you can pass to functions.
- Strings become byte slices. Text is a slice of bytes, usually []const u8, and you decide when UTF-8 matters.
- Promises and async become std.Io. Functions that do input or output take an Io value. You choose whether work is merely asynchronous or truly concurrent, and the difference matters (section 4).
- npm becomes the build system. Dependencies are declared in a build.zig.zon file and wired up in build.zig, which is itself Zig code.
One warning that every newcomer hits: Zig is not yet at version 1.0, and its standard library changes between releases. The input and output API was reworked around std.Io, so tutorials written for older versions often do not compile. Pin your compiler version and read the release notes before upgrading.
3. Project 1: a word-count CLI
This program reads standard input and prints line, word and byte counts as JSON. Save it as wcjson.zig:
const std = @import("std");
const Io = std.Io;
const Counts = struct { lines: usize = 0, words: usize = 0, bytes: usize = 0 };
fn countChunk(c: *Counts, in_word: *bool, chunk: []const u8) void {
c.bytes += chunk.len;
for (chunk) |b| {
if (b == '\n') c.lines += 1;
const space = b == ' ' or b == '\n' or b == '\t' or b == '\r';
if (space) {
in_word.* = false;
} else if (!in_word.*) {
in_word.* = true;
c.words += 1;
}
}
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
var in_buf: [4096]u8 = undefined;
var stdin_reader: Io.File.Reader = .init(.stdin(), io, &in_buf);
const stdin = &stdin_reader.interface;
var counts: Counts = .{};
var in_word = false;
var chunk: [4096]u8 = undefined;
while (true) {
const n = try stdin.readSliceShort(&chunk);
if (n == 0) break;
countChunk(&counts, &in_word, chunk[0..n]);
}
var out_buf: [256]u8 = undefined;
var stdout_writer: Io.File.Writer = .init(.stdout(), io, &out_buf);
const out = &stdout_writer.interface;
try out.print("{{\"lines\":{d},\"words\":{d},\"bytes\":{d}}}\n", .{ counts.lines, counts.words, counts.bytes });
try out.flush();
}Build it and try it:
zig build-exe wcjson.zig -O ReleaseSmall
printf 'hello world\nfoo bar baz\n' | ./wcjsonThings to notice, coming from JavaScript:
- main takes an Init value. It carries the Io instance, the allocators, the arguments and the environment. Nothing is global.
- All input and output goes through Io. You create a reader and a writer on top of a buffer you own.
- Output is buffered, so you must flush. Forget the final flush and nothing appears.
- The loop reads in 4 KB chunks. The program never holds the whole input, so it handles files larger than memory.
- try appears on every call that can fail. The compiler will not let you ignore an error by accident.
4. Project 2: a small JSON HTTP server
This server has three behaviors: a health check, a greeting that validates its input, and a JSON 404. It handles each connection concurrently and supports keep-alive. Save it as server.zig:
const std = @import("std");
const Io = std.Io;
const net = Io.net;
const http = std.http;
const json_headers = [_]http.Header{
.{ .name = "content-type", .value = "application/json" },
};
fn validName(name: []const u8) bool {
if (name.len == 0 or name.len > 64) return false;
for (name) |c| {
if (!std.ascii.isAlphanumeric(c)) return false;
}
return true;
}
fn route(req: *http.Server.Request) !void {
const target = req.head.target;
if (std.mem.eql(u8, target, "/health")) {
return req.respond("{\"status\":\"ok\"}\n", .{ .extra_headers = &json_headers });
}
const prefix = "/hello/";
if (std.mem.startsWith(u8, target, prefix)) {
const name = target[prefix.len..];
if (!validName(name)) {
return req.respond("{\"error\":\"bad name\"}\n", .{
.status = .bad_request,
.extra_headers = &json_headers,
});
}
var buf: [160]u8 = undefined;
const body = try std.fmt.bufPrint(&buf, "{{\"hello\":\"{s}\"}}\n", .{name});
return req.respond(body, .{ .extra_headers = &json_headers });
}
return req.respond("{\"error\":\"not found\"}\n", .{
.status = .not_found,
.extra_headers = &json_headers,
});
}
fn handle(io: Io, stream: net.Stream) void {
defer stream.close(io);
var in_buf: [4096]u8 = undefined;
var out_buf: [4096]u8 = undefined;
var reader = stream.reader(io, &in_buf);
var writer = stream.writer(io, &out_buf);
var server = http.Server.init(&reader.interface, &writer.interface);
while (true) {
var req = server.receiveHead() catch return; // client closed or sent junk
route(&req) catch return;
if (!req.head.keep_alive) return;
}
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const arena = init.arena.allocator();
const args = try init.minimal.args.toSlice(arena);
const port: u16 = if (args.len > 1) try std.fmt.parseInt(u16, args[1], 10) else 8080;
const addr = try net.IpAddress.parseIp4("127.0.0.1", port);
var listener = try addr.listen(io, .{ .reuse_address = true });
defer listener.deinit(io);
std.debug.print("listening on 127.0.0.1:{d}\n", .{port});
var group: Io.Group = .init;
defer group.cancel(io);
while (true) {
const stream = listener.accept(io) catch |err| {
std.debug.print("accept failed: {t}\n", .{err});
continue;
};
// concurrent() guarantees a unit of concurrency or fails; async() may run the
// handler inline, and one idle client would then block the accept loop.
group.concurrent(io, handle, .{ io, stream }) catch {
stream.close(io); // no concurrency available: shed this connection
};
}
}Build and run it, then call it from another terminal:
zig build-exe server.zig -O ReleaseSafe
./server 8080
curl -i http://127.0.0.1:8080/health
curl http://127.0.0.1:8080/hello/alice
curl -i 'http://127.0.0.1:8080/hello/a%22b' # 400: rejected by validNameWhat the code teaches:
- Input is validated before it reaches JSON. The greeting is built by formatting text into a buffer. Without validName, a name containing a quote could break out of the JSON string. Zig will not stop you from writing that bug.
- Buffers are fixed-size and on the stack. A name that does not fit makes bufPrint return an error, which route passes up to handle, which closes the connection. There is no hidden growth.
- async is not concurrent. The first version most people write uses group.async for each connection. That call makes no promise that the handler runs in parallel, and with a blocking handler one idle client can stall the accept loop. group.concurrent guarantees a unit of concurrency or returns an error, and the code above sheds the connection in that case. Section 6 includes a check you can run to see the difference yourself.
- Each connection owns its stream. The handler closes it with defer, whichever path it exits by.
This is a demonstration, not a production server. It speaks plain HTTP/1.1 on loopback, has no timeouts, no request-size policy beyond the 4 KB header buffer, and no TLS. Put it behind a reverse proxy or a tunnel that terminates TLS and enforces timeouts.
For comparison, here is the same behavior in Node, which you can use for the measurements below. Save it as node_server.js:
const http = require('node:http');
const port = Number(process.argv[2] || 8080);
const send = (res, status, obj) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(obj) + '\n');
};
http.createServer((req, res) => {
if (req.url === '/health') return send(res, 200, { status: 'ok' });
if (req.url.startsWith('/hello/')) {
const name = req.url.slice('/hello/'.length);
if (!/^[A-Za-z0-9]{1,64}$/.test(name)) return send(res, 400, { error: 'bad name' });
return send(res, 200, { hello: name });
}
send(res, 404, { error: 'not found' });
}).listen(port, '127.0.0.1');The Node version is shorter, and JSON.stringify makes the injection bug impossible by construction. That is a real advantage of the ecosystem, and worth remembering when you decide what to port.
5. Build modes and cross-compiling
Zig has four optimize modes, and the choice matters for both size and safety:
- Debug builds fast, runs slowly, keeps all safety checks and includes debug information. Use it while developing.
- ReleaseSafe optimizes but keeps runtime safety checks such as bounds and overflow checks. This is the sensible default for anything that handles untrusted input, including the server above.
- ReleaseFast optimizes for speed and turns the safety checks off.
- ReleaseSmall optimizes for size and also turns the safety checks off.
The smallest binaries come with the checks off, so you are trading safety for bytes. For a network-facing service, think hard before choosing either of the last two modes.
This script builds the CLI in every mode, with and without debug symbols stripped, and then cross-compiles it. Run it in the folder with wcjson.zig:
for mode in Debug ReleaseSafe ReleaseFast ReleaseSmall; do
zig build-exe wcjson.zig -O $mode -femit-bin=wc_$mode
zig build-exe wcjson.zig -O $mode -fstrip -femit-bin=wc_${mode}_strip
done
ls -l wc_*
for t in x86_64-linux aarch64-linux x86_64-windows aarch64-macos; do
zig build-exe wcjson.zig -O ReleaseSmall -fstrip -target $t -femit-bin=wc_x_$t
done
ls -l wc_x_*
file wc_ReleaseSmall # static executable, no shared librariesThree things to check in the output: how much of a debug build is symbols (strip removes them), how far ReleaseSmall is from ReleaseSafe, and that the Linux binary is statically linked. The cross-compiled files are not named with an extension because of how the output name is set, so rename the Windows one to end in .exe before running it.
One caution when you upgrade the compiler. Zig 0.16 moved I/O onto std.Io, and a port of a well-known command-line tool reported larger binaries after moving to it, which its author hoped later releases would claw back. Re-measure your own sizes after every compiler upgrade instead of assuming they stay put.
6. Measure it on your own machine
This script starts any server, waits for the first response, reads its resident memory from the operating system, sends 2,000 requests and reads memory again. It uses the Linux proc filesystem, so it runs on Linux only. Save it as measure.py:
import subprocess, sys, time, urllib.request
def rss_mb(pid):
with open(f'/proc/{pid}/status') as f:
for line in f:
if line.startswith('VmRSS:'):
return int(line.split()[1]) / 1024
def wait_ready(url, timeout=10):
t0 = time.perf_counter()
while time.perf_counter() - t0 < timeout:
try:
urllib.request.urlopen(url, timeout=0.5).read()
return (time.perf_counter() - t0) * 1000
except OSError:
time.sleep(0.005)
raise SystemExit('server did not come up')
port, cmd = int(sys.argv[1]), sys.argv[2:]
proc = subprocess.Popen(cmd, stderr=subprocess.DEVNULL)
try:
url = f'http://127.0.0.1:{port}/health'
ready_ms = wait_ready(url)
time.sleep(0.3)
idle = rss_mb(proc.pid)
for _ in range(2000):
urllib.request.urlopen(url).read()
after = rss_mb(proc.pid)
print(f'start-to-first-response: {ready_ms:7.1f} ms | RSS idle: {idle:6.1f} MB | RSS after 2000 requests: {after:6.1f} MB')
finally:
proc.terminate(); proc.wait()Run each server several times and keep the median:
python3 measure.py 8101 ./server 8101
python3 measure.py 8102 node node_server.js 8102
ls -l ./server $(which node)The last line also matters for the title. A Node single-executable application embeds the Node runtime itself, so its size starts at the size of the node binary and grows from there.
This script does not measure throughput. A single-threaded Python client is the bottleneck long before either server is. For requests per second, use a dedicated load tool such as wrk or oha, and report latency percentiles along with throughput.
Finally, check the concurrency behavior described in section 4. With the server running on port 8080, this opens 50 idle connections and then makes a normal request. It should answer immediately:
import socket, time, urllib.request
held = [socket.create_connection(('127.0.0.1', 8080)) for _ in range(50)]
t = time.time()
body = urllib.request.urlopen('http://127.0.0.1:8080/hello/alice', timeout=5).read()
print(body.decode().strip(), f'({(time.time() - t) * 1000:.1f} ms with 50 idle connections open)')
for h in held:
h.close()If you change group.concurrent back to group.async, you can see for yourself whether the request still gets through on your compiler version.
Record your own results:
- Zig server binary size: ___ KB
- Node binary size: ___ MB
- Start to first response, Zig: ___ ms
- Start to first response, Node: ___ ms
- Memory at idle, Zig: ___ MB
- Memory at idle, Node: ___ MB
- Memory after 2,000 requests, Zig: ___ MB
- Memory after 2,000 requests, Node: ___ MB
[Editor's note: fill in the blanks with the numbers from your own machine, and state your CPU, operating system, Zig version and Node version, before publishing. Do not publish estimates as measurements.]
7. What you give up, and when not to switch
- The ecosystem. There is no npm. Libraries for databases, authentication, validation and web frameworks are far fewer and less mature, and you will write more yourself.
- A moving standard library. Before 1.0, APIs change between releases. Expect to update code when you upgrade the compiler, and pin the version in CI.
- Manual memory management. You decide who allocates and who frees. The debug allocator catches leaks and some misuse in Debug builds, but bugs that a garbage collector would hide are now yours.
- Fewer safety nets in the smallest builds. As section 5 explained, the modes that give the smallest and fastest binaries turn runtime checks off.
- Protocol coverage. The standard library's HTTP server targets HTTP/1.1. For TLS termination, HTTP/2 and similar needs, place a proxy in front.
- Team cost. Hiring, code review and on-call debugging are all easier in a language your team already knows.
Zig is a good fit for command-line tools, small sidecar services, agents that must run on machines you do not control, programs that wrap C libraries, and anything where a single static file is a feature. It is a poor fit for large business applications whose value is in the libraries they use.
8. A sensible migration path
Do not rewrite your backend. Carve out one small piece:
- Pick a service with narrow input and output, such as a webhook receiver, a file converter or a health agent.
- Write black-box tests against its HTTP interface first, so the Node version defines the correct behavior.
- Port it, using ReleaseSafe, and run the same tests against both.
- Measure with the scripts above, on your hardware and with your real payloads.
- Run both versions behind your proxy and shift a small share of traffic to the Zig one.
- Keep Node for everything else, and keep the Node version available until the Zig one has been in production long enough to trust.
9. Checklist
- Pin the Zig version in your build and your CI.
- Validate every piece of input before it goes into output you build by hand.
- Use ReleaseSafe for network-facing code unless you can justify turning checks off.
- Use group.concurrent when each connection needs its own unit of concurrency.
- Put TLS, timeouts and request limits in a proxy in front of the service.
- Measure size, memory and start-up on your machine, and again after each compiler upgrade.
- Port one small service first, behind a canary.
Summary
Zig lets a JavaScript developer ship a small service as one static file, with memory use and start-up time that a Node process cannot match. The price is a young ecosystem, a moving standard library and memory you manage yourself. Try it on one narrow service, measure it honestly on your own hardware, and let the numbers and your team's comfort decide how far to go.
