Building a Real-Time, Event-Sourced Feature Flag System with Rust and WebAssembly
Building a Real-Time, Event-Sourced Feature Flag System with Rust and WebAssembly
Disabling or enabling features at runtime is a common need in modern software. A robust feature flag system lets product teams experiment safely, roll out changes gradually, and reduce blast radius during incidents. In this tutorial, you’ll build a lightweight, real-time, event-sourced feature flag service composed of: a Rust-based event store, a WebAssembly (wasm) client surface, and a small Node.js server for orchestration. The approach emphasizes correctness, observability, and low latency, while staying approachable for practical use in real projects.
What you’ll learn
- Principles of event sourcing for feature flags and how it improves auditability and rollbacks
- Designing a compact event schema for feature flag lifecycle events
- Implementing a Rust event store with append-only logs and snapshotting
- Exposing a WASM module that can be embedded in frontend apps to evaluate feature flags with minimal latency
- Real-time updates via server-sent events (SSE) and a simple poller fallback
- Basic authorization, schema migrations, and observability considerations
Overview of the architecture
- Event Store (Rust): An append-only log backed by a simple file-based database that supports snapshots. It stores events such as FlagCreated, FlagUpdated, FlagDeleted, and FlagEval.
- WASM Client (Rust/WASM): A small library compiled to WebAssembly that provides a high-performance feature flag evaluator. It subscribes to events and applies the latest state to determine whether a flag is enabled for a user or context.
- API Server (Node.js): A tiny HTTP server that serves the WASM module, provides endpoints for creating/updating flags, and streams updates to clients via Server-Sent Events.
- Frontend integration: The WASM evaluator can be instantiated in the browser, receiving real-time updates and evaluating flags per user context.
Prerequisites
- Knowledge of Rust basics (ownership, structs, enums)
- Basic understanding of WebAssembly and how to use Rust to compile to wasm
- Node.js installed for the API server
- A small HTTP client or Postman for testing endpoints
- Optional: Docker to run components in containers
Project layout
- event-store/ (Rust crate for the event store)
- wasm-evaluator/ (Rust crate compiled to wasm)
- api-server/ (Node.js server to manage flags and SSE)
Part 1: Designing the event schema
We’ll keep the event schema compact and forward-compatible. Each event has a type, a flag_id, a version, a timestamp, and a payload.
- FlagCreated: { flag_id, name, description, enabled_by_default, variants }
- FlagUpdated: { flag_id, name?, description?, enabled_by_default?, variants? }
- FlagDeleted: { flag_id }
- FlagEval: { flag_id, context (e.g., user_id, ip, cohort), enabled (bool), reason }
Event ordering guarantees deterministic state reconstruction.
Part 2: Implementing the Rust event store
Goal: provide append-only writes, read current state, and emit deltas for subscribers.
Key components
- Event enum with serde serialization
- Append-only log stored as files per flag or a single log file
- In-memory index to quickly compute current flag state
- Snapshot mechanism to speed startup
Code sketch (Rust)
- Cargo.toml dependencies: serde, serde_json, chrono, tokio, futures, tracing
Main structures
- struct Event { id: Uuid, event_type: EventType, flag_id: String, version: u64, timestamp: DateTime, payload: serde_json::Value }
- enum EventType { FlagCreated, FlagUpdated, FlagDeleted, FlagEval }
Core functions
- append_event(flag_id, event_type, payload)
- load_events(flag_id) -> Vec
- build_snapshot(flag_id) -> FlagState
- subscribe_updates() -> Stream
Example (high-level, simplified)
- When FlagCreated arrives, create initial state: { flag_id, name, enabled_by_default, variants, is_deleted: false, version: 1 }
- FlagUpdated merges fields; increment version
- FlagDeleted marks is_deleted and sets version
Notes
- Use a simple file-per-flag storage for clarity, or a single log with rotation.
- Implement compaction: periodically create a snapshot file representing the current flag state, and trim older events.
Part 3: Building the WASM evaluator
Goal: deliver a fast, deterministic evaluation function that frontend apps can use locally.
Why WASM? Reduces JS overhead for frequent evaluations and enables offline or offline-ish experiences.
WASM design
- The evaluator exposes a function evaluate(flag_state, context) -> bool
- flag_state is a compact representation: flag_id, enabled_by_default, variants, current_value (derived)
- context is a small map-like structure (user_id, cohort, environment)
Rust code outline (lib.rs)
- #[wasm_bindgen] pub fn evaluate(flag_state_json: &str, context_json: &str) -> bool
- Deserialize flag_state and context using serde_json
- Apply rule: if there is a flag-specific Override or a default, return the evaluated boolean
- Return a boolean that frontend can consume
Build steps
- Use wasm-pack to compile to wasm
- Ensure compatibility with popular bundlers (webpack, esbuild)
- Export the evaluate function and a simple init for subscriptions
Frontend surface
-
A small wrapper around the wasm module that:
- Maintains the latest flag_state from the server
- Provides a function is_enabled(flag_id, context) that calls into wasm
- Subscribes to SSE for real-time updates and triggers wasm state refresh
Part 4: The API server (Node.js)
Responsibilities
- Create, update, delete flags
- Append events to the Rust event store
- Serve the WASM module to clients
- Provide an SSE endpoint /flags/stream that streams FlagUpdated and FlagDeleted events
Security and validation
- Simple API key check for demo
- Validate payload schemas with a lightweight validator
- Rate-limit to protect the endpoint
Example endpoints
- POST /flags -> create a new flag Body: { flag_id, name, description, enabled_by_default, variants }
- PATCH /flags/:flag_id -> update a flag Body: { name?, description?, enabled_by_default?, variants? }
- DELETE /flags/:flag_id -> delete a flag
- GET /wasm/flag-evaluator.wasm -> serve wasm module
- GET /flags/stream -> SSE stream of events
Implementation notes
- On server start, load the current state from the event store to seed the wasm evaluator
- When an event is appended, push the event to all connected SSE clients
- Provide a small helper to convert events into a delta that clients can apply incrementally
Part 5: Observability and testing
Observability
- Structured logging with tracing for Rust components
- Metrics: event write rate, lag between event time and processed time
- Health endpoints for each component
Testing strategy
- Unit tests for event application logic in Rust
- Integration test simulating creating a flag, updating it, and streaming events
- End-to-end test with API server and wasm evaluator
Concrete code examples
Note: The following are condensed snippets to illustrate key ideas. They are not a full, runnable project but provide concrete patterns you can implement.
Rust event (serde-based)
use serde::{Serialize, Deserialize};
use chrono::{DateTime, Utc};
use serde_json::Value;
[derive(Serialize, Deserialize, Debug, Clone)]
pub enum EventType {
FlagCreated,
FlagUpdated,
FlagDeleted,
FlagEval,
}
[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Event {
pub id: String, // UUID
pub event_type: EventType,
pub flag_id: String,
pub version: u64,
pub timestamp: DateTime,
pub payload: Value,
}
impl Event {
pub fn new(event_type: EventType, flag_id: &str, version: u64, payload: Value) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
event_type,
flag_id: flag_id.to_string(),
version,
timestamp: Utc::now(),
payload,
}
}
}
WASM evaluator (lib.rs, simplified)
use wasm_bindgen::prelude::*;
use serde_json::Value;
[wasm_bindgen]
pub fn evaluate(flag_state_json: &str, context_json: &str) -> bool {
// Basic deserialization
let state: Value = serde_json::from_str(flag_state_json).unwrap_or_else(|| Value::Null);
let context: Value = serde_json::from_str(context_json).unwrap_or_else(|| Value::Null);
// Simple logic: if flag has "enabled" boolean in state, return it; otherwise default
if let Some(enabled) = state.get("enabled").and_then(|v| v.as_bool()) {
return enabled;
}
// Fallback to enabled_by_default
if let Some(def) = state.get("enabled_by_default").and_then(|v| v.as_bool()) {
return def;
}
false
}
api-server (Node.js, sketch)
const express = require('express');
const { Readable } = require('stream');
const app = express();
app.use(express.json());
let events = []; // In-memory for demo; in production, pull from Rust event store
app.post('/flags', (req, res) => {
const { flag_id, name, description, enabled_by_default, variants } = req.body;
const e = { event_type: 'FlagCreated', flag_id, version: 1, payload: { name, description, enabled_by_default, variants } };
events.push(e);
res.status(201).json({ ok: true, event: e });
});
app.get('/flags/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.flushHeaders();
const onEvent = (ev) => {
res.write(data: ${JSON.stringify(ev)}\n\n);
};
// In a real app, subscribe to Rust event store
// Here we simulate with a timer or immediate push
const id = setInterval(() => {
if (events.length) onEvent(events[events.length - 1]);
}, 1000);
req.on('close', () => clearInterval(id));
});
app.listen(3000, () => {
console.log('API server listening on port 3000');
});
How to run (high level)
- Build the Rust event store:
- cd event-store
- cargo build release
- Build the WASM evaluator:
- cd wasm-evaluator
- wasm-pack build target web
- Run API server:
- cd api-server
- npm install
- node index.js
Putting it together in practice
- Start by implementing the event store with a simple append-only log. Write tests that ensure events are replayable to reconstruct state.
- Build the wasm evaluator and expose a minimal evaluate function. Integrate it into a small frontend page that loads the wasm, fetches flag state, and evaluates a few sample contexts.
- Create a lightweight API to manage flags and emit events. Ensure the SSE stream delivers incremental updates and that clients can rebuild state from the event log if needed.
- Iterate on observability: add tracing spans around event writes and state recomputation; add a simple dashboard to show current flag states and last update times.
Next steps and refinements
- Extend the event schema with per-flag evaluation rules (e.g., percentage rollouts, user cohorts).
- Add a compatibility layer to migrate old flag data into the new event format.
- Implement durable storage for the event log, e.g., SQLite or RocksDB-backed log with compaction.
- Harden security: API authentication, authorization, and audit trails.
- Consider a richer WASM surface: expose an API to subscribe to specific flag updates and to reset evaluator state.
Would you like me to tailor this tutorial to a particular tech stack you’re using (e.g., prefer Python for the API, or you want the WASM evaluator to run in a React app with Vite)? I can also provide a fuller, runnable code scaffold for Rust and Node.js with concrete file structures and complete build scripts.
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)