Hello, hello! It's me Owen again!
I've been inactive for awhile and thought maybe I should post again (Even if this is my second post fully). Some people say that coding project should start with To-do lists, REST APIs, Generators, etc.
Instead, I decided to dive completely into the deep end. I wanted to build something fast, learn about memory management, and figure out how different languages talk to each other.
So, I built Dunena—a high-performance, hybrid-architecture monorepo. It leverages Bun and TypeScript for the web layer, and delegates heavy CPU tasks to Zig via a Foreign Function Interface (FFI). Oh, and I deployed it on Kubernetes. Because why not? 😅 (But don't worry about that since I'm still new to Docker and Kubernetes.)
🔗 Check out the Dunena Repository on GitHub here!
🔗 Check the documentation here!
Here is a breakdown of what I built, how it works, and the massive amount of things I learned along the way.
🏗️ What is Dunena?
At its core, Dunena is a backend platform designed to handle requests quickly and efficiently. It features routing, WebSockets, pub/sub services, and a caching layer.
But the real magic is under the hood. I wanted the rapid development speed of TypeScript, but I didn't want Node/Bun to get bogged down by heavy computations like compression, bloom filters, or complex stat calculations.
The Tech Stack:
- Runtime & Monorepo Manager: Bun 🥟
- Primary Language: TypeScript (Strict mode)
- High-Performance Core: Zig ⚡
- Database: SQLite 🗄️
- Deployment: Docker & Kubernetes 🐳 (Again I'm new to this so bare in mind)
🧠 The Architecture
The coolest (and scariest) part of this project is the FFI bridge. Here is how a request goes from TypeScript to near bare-metal execution in Zig.
First, I write the high-performance logic in Zig and export it using the C Application Binary Interface (ABI):
// zig/src/exports.zig
const std = @import("std");
// Exporting a function so Bun can read it via the C ABI
export fn compute_heavy_stats(input_val: i32) i32 {
// Imagine some incredibly complex, CPU-blocking math here
var result = input_val * 42;
return result;
}
Then, in my TypeScript platform package, I use Bun's native dlopen to load the compiled Zig shared library and bridge the function:
// packages/platform/src/bridge/ffi.ts
import { dlopen, FFIType, suffix } from "bun:ffi";
// Load the compiled Zig library
const path = `../../zig/zig-out/lib/libdunena_core.${suffix}`;
const { symbols } = dlopen(path, {
compute_heavy_stats: {
args: [FFIType.i32],
returns: FFIType.i32,
},
});
// Now I can call Zig directly from TypeScript!
export function runStats(input: number): number {
return symbols.compute_heavy_stats(input);
}
When a client hits my Bun server (apps/server/src/index.ts), the platform handles the API routing, hands the heavy computation off to Zig, and returns the result instantly.
🤖 The "AI" Elephant in the Room
I want to be totally transparent: I used AI coding agents to help build this. Using tools like Claude and Gemini was incredible for speeding up the boilerplate, setting up the monorepo structure, and scaffolding the initial file architecture. It felt like having a senior developer sitting next to me typing out the boring stuff.
However... AI agents are terrifyingly bad at manually managing memory across an FFI boundary.
If the AI changed a data type in Zig but forgot to update the exact corresponding FFI definition in TypeScript, the server would just crash with a memory access violation. The AI helped me move fast, but I still had to do the brutal, hair-pulling debugging and fixing. Figuring out how to safely pass pointers, prevent memory leaks, and get Kubernetes to play nice with an attached SQLite volume was all human effort. It taught me not to blindly trust generated code, especially when dealing with low-level systems.
🧗 The Biggest Challenges
1. Managing Memory Across the Void
When you pass data between TypeScript and Zig, there are no safety nets. Bun’s garbage collector has no idea what Zig is doing. Learning how to manually manage memory and use defer statements in Zig was a huge hurdle!
2. SQLite in Kubernetes
Deploying an SQLite database (which is essentially a physical file) attached to a K8s pod via Persistent Volume Claims (PVC) was tricky. It works great for a single pod, but I quickly realized that if I scale this horizontally, I might run into database locking issues.
🚀 What's Next?
This project is far from "finished." I'm currently maintaining this project on my own, but would love to see anyone who'd try and contribute!
Let's Connect!
Since this is my first major project, I would absolutely love any feedback, code reviews, or advice from the community.
🔗 Check out the code on GitHub
🔗 Check the documentation here!
Have you ever tried mixing languages like this or fighting with FFI boundaries? Let me know in the comments! 👇
Top comments (1)
Owen, massive kudos for diving straight into the deep end! 🚀 Building a hybrid-architecture monorepo with Bun and Zig as a first major project is wildly ambitious.
I completely resonate with your experience using AI agents. As someone who tests agentic workflows and audits complex systems, your observation about the FFI boundary is spot on. LLMs are terrible at spatial awareness across C ABIs. The hair-pulling debugging you did is exactly the high-value, human-in-the-loop work that AI can't replace right now.
Coming at this from a Senior QA and Systems Testing perspective, I love breaking architectures to see where they leak. Here is a quick "Security & Stability Audit" of a few hidden traps you will definitely want to test for as you scale Dunena:
The Fault: You mentioned using Zig to offload "heavy CPU tasks." By default, synchronous FFI calls in Bun (symbols.compute_heavy_stats()) block the main JavaScript event loop. If Zig takes 500ms to calculate a bloom filter, your Bun server cannot process any other incoming HTTP requests or WebSocket pings during that half-second.
The Fix: You need to audit your load testing. To fix this, you should either wrap the Zig FFI calls inside Bun Worker threads, or utilize asynchronous FFI bindings so Zig can do the heavy lifting on a background thread without starving your web server.
The Fault: What happens if a user sends a malicious or malformed i32 payload that causes your Zig code to panic (e.g., an unexpected divide-by-zero or out-of-bounds array access)? In a standard Node/Bun app, you just catch the error. But if Zig panics across the C ABI boundary, it will instantly crash the entire Bun process. An attacker could use this as a trivial Denial of Service (DoS) vector.
The Fix: Never let Zig panic across the boundary. Catch all errors inside Zig and return a C-compatible struct containing a status code (e.g., 0 for success, 1 for error) alongside the result. Then, throw a proper TypeScript error on the Bun side based on that code.
The Fault: You accurately noted the database locking issue with Kubernetes PVCs, but it's actually worse: if you horizontally scale pods pointing to the same physical SQLite file on a shared volume, you risk outright database corruption due to race conditions and mismanaged file locks across different containers.
The Fix: If you want to keep the speed of SQLite without the heavy footprint of Postgres, look into LiteFS (by Fly.io). It performs distributed SQLite replication at the filesystem level, allowing you to have one safe primary write-pod and multiple read-replica pods in K8s without locking conflicts.
Stellar work acknowledging the tradeoffs and being transparent about the tooling. It takes a lot of grit to build something this complex from scratch. I've dropped a star on the repo!
What is your current strategy for load-testing the WebSockets under heavy traffic? Would love to see how the Bun/Zig bridge holds up under pressure!