Building a Time Tracker in Zig Instead of Go
I had been using Thyme, Sourcegraph's open-source Go tool that tracks which applications are active every thirty seconds. It does the right things: automatic tracking, local storage, no account, no network sync. The data stays on your machine as JSON, and you generate HTML charts when you want to look at it.
The concept was right. The workflow was not quite what I wanted. Thyme is a CLI. You run it in a loop, it writes JSON to a file, and when you want to see your day you run a second command that produces a static HTML page. That works, but it means the data is never visible until you ask for it. I wanted something I could glance at — a live timeline sitting in a GTK4 panel, a system tray icon, collapsible hour blocks, search. A native Linux desktop application rather than a terminal pipeline.
I also wanted to build it myself. Thyme proved the concept was useful. The question was whether I could make a version that felt like it belonged on the desktop rather than in a shell session.
Why native mattered
A browser-based time tracker or an Electron wrapper would have been quicker to build. But the tool runs all day, every day, on my desktop. Resource usage matters in a way it does not for something that starts, does a job, and exits.
A native GTK4 application draws its interface using the same toolkit as the rest of my desktop. It does not bundle a second copy of Chromium. It does not allocate hundreds of megabytes to render a sidebar. When it is minimised to the system tray it effectively disappears from the process list. That is the bar I wanted: a tool quiet enough that I forget it is running.
Storing data in SQLite rather than JSON files also changed what the tracker could do. Thyme writes a flat JSON dump each cycle. That is simple, but querying across days or filtering by application means loading and parsing the full file. SQLite gave me indexed lookups, grouped statistics, and the ability to sync selected entries to Google Calendar without reading every record back into memory.
Where Go stopped being the obvious choice
Go was the natural starting point. Thyme is written in Go, I had been writing Go services, and a thirty-second polling loop is hardly a concurrency challenge.
The trouble is that a native Linux desktop application spends most of its time talking to C libraries. X11 for reading window properties. XScreenSaver for idle detection. GTK4 for the interface. Cairo for custom drawing. libcurl for downloading favicons in the background. On Wayland, the tracker also needs to call into Sway, Hyprland, KDE, or GNOME depending on which compositor is running.
In Go, every one of those calls crosses the cgo boundary. That is not just a performance cost, though it adds up when the GUI makes hundreds of GTK calls to build a scrollable timeline with Cairo-drawn charts. The larger issue is that cgo changes the shape of a project. Cross-compilation breaks. Errors from the C side arrive as opaque panics. Callbacks from C into Go need careful coordination with the garbage collector. The build gets heavier and less portable.
For a web service these trade-offs barely register. For a desktop application whose entire purpose is calling C libraries, they sit at the centre of every decision.
What Zig gave the project
Zig treats C headers as importable modules. There is no wrapper layer, no FFI declaration file, no separate compilation step:
const c = @cImport({
@cInclude("X11/Xlib.h");
@cInclude("X11/extensions/scrnsaver.h");
});
After that, c.XOpenDisplay and c.XGetWindowProperty are ordinary function calls. The same pattern works for SQLite, libcurl, and every other C library the tracker touches. In the compiled binary there is no boundary between Zig code and C code because they share the same calling convention.
That changes what a native application feels like to build. The GUI is constructed entirely through GTK4's GObject bindings — every button, label, scrolled window, and Cairo draw call is a direct invocation into a C library, not a marshalled round trip. Building a custom donut chart with anti-aliased arcs means calling cairo.arc, cairo.setSourceRgba, and cairo.fill as though they were part of the application's own code. There is no impedance mismatch between "my code" and "the toolkit."
The tracker detects the display server at runtime — checking environment variables for XDG_SESSION_TYPE, SWAYSOCK, HYPRLAND_INSTANCE_SIGNATURE, or the desktop session name — and calls the right backend. On X11 that means Xlib and XScreenSaver. On Wayland it might mean shelling out to swaymsg or talking to GNOME's Mutter over D-Bus. Each backend calls different C functions or spawns different child processes. In Zig, none of that requires a special build mode or a different compilation strategy. It is all just code.
Predictable resource usage
The daemon runs on a separate OS thread and samples windows every thirty seconds. Between samples it sleeps. When it has new data it schedules a GLib idle callback so the GUI updates on the main thread:
_ = glib.idleAdd(refresh_callback, null);
One line, no channel, no mutex around the GUI state. GTK calls back when its main loop is ready.
The memory model follows the same logic. The daemon uses an arena allocator that resets after every sampling cycle:
_ = daemon_arena.reset(.retain_capacity);
Each pass allocates what it needs, records the samples, and releases everything at once. There is no garbage collector deciding when to pause, no finaliser running at an unpredictable moment. For a tool that sits in the background all day, that predictability was the point.
The favicon downloader runs its own thread with a mutex-protected queue and calls libcurl directly — curl_easy_perform, fopen, fwrite — without involving a runtime scheduler. Icons are cached to the XDG cache directory and loaded inline in the timeline. The whole pipeline is C functions called from Zig with no overhead at the boundary.
The build system as a trade-off
Go's go build is genuinely simple when the project is pure Go. Zig's build system is a Zig program, and for this project it earned its complexity.
The build.zig file runs a GObject introspection tool to generate GTK4 bindings, compiles a separate C executable for the system tray applet (GTK4 dropped tray icon support, so the applet uses GTK3), links against six system libraries, and produces the final binary. That is more involved than go build. But the complexity is programmable rather than configured through environment variables and build tags. When something breaks, I debug a Zig program rather than guessing at flag combinations.
I would structure build.zig better if I started again. It grew to over three hundred lines because I added each library incrementally. Separating the GObject generation, the tray applet, and the main binary into distinct build steps from the start would have saved refactoring later.
What building native actually cost
Zig is not a mature ecosystem. The documentation has gaps. Compiler errors are sometimes cryptic. Libraries that exist as polished crates in Rust or well-maintained packages in Go simply do not exist in Zig yet. I wrote more code by hand than I would have in either of those languages — string parsing without a regex library, HTTP callbacks with manual pointer casts, GUI layout without a visual editor.
The error handling helps with that volume. Zig and Go share the same philosophy — errors are values, not exceptions — but Zig's try keyword and defer cleanup reduce the per-call ceremony:
try db.init_db();
defer db.close();
var trk = try tracker.Tracker.init(allocator);
defer trk.deinit();
That brevity adds up when every other line is a fallible call into a C library. It does not make the ecosystem larger, but it makes the code that fills the gaps less painful to write.
What I would do differently
I would write integration tests for the Wayland compositor detection earlier. The tracker checks environment variables to decide whether it is on X11, Sway, Hyprland, KDE, or GNOME. That logic works, but I debugged it manually on each desktop environment. Mocked environment variables in automated tests would have caught edge cases faster.
I would also invest earlier in a proper development loop for the GTK4 interface. Building the GUI programmatically in Zig is powerful but slow to iterate on. Some kind of hot-reload or at least a faster compile-run cycle for layout changes would have saved time.
What changed
The finished tracker does what I wanted. It runs quietly in the background, samples windows, shows a live searchable timeline with Cairo-drawn usage charts, and syncs to Google Calendar when I ask it to. The binary is small, the memory footprint is stable, and the system tray icon sits alongside the rest of my desktop without looking like a foreign import.
The part I did not expect was how building something native changed what I think is worth building natively. Most of my work is services and web applications, and Go is the right tool for that. But for a program that lives on the desktop and talks to the display server, the toolkit, and the filesystem all day, there is a simplicity in removing the runtime layer between the application and the system it sits on. The code does what it says. The resource usage is what you would predict from reading it. Nothing is quietly managing memory or scheduling threads on your behalf.
That is not always worth the trade-off. Zig's ecosystem is young, the tooling is rough in places, and the productivity cost is real. But for this project — a quiet, native, long-running desktop tool — the trade-off pointed clearly in one direction.