Where tflo fits
tflo is an embeddable temporal event-processing engine. It runs alone where JVM CEP engines can't reach, and it composes with the engines you already operate where they do. The framing is win-win composition, not substitution.
This page also serves as an example application of the tflo engine via its WASM binding — specifically the React integration built on top of it. That integration is an example application, not the product; the product is the engine. See the bindings docs for the full WASM / JS surface, and the examples index for all available example applications (including the framework-agnostic browser-events example).
Declare it, don't manage it.
No useEffect, no useRef, no timers to wire up and
remember to clear. You write a pattern — when A, followed by B, unless C
within T — and the engine owns the rest. Your React component stays a pure
function of what the engine tells it.
Stateless components, stateful (replayable) engine.
Your components hold no temporal state — not "did A arrive before B within 5 s?" The engine owns that, in an explicit, typed projection. And because the engine is deterministic and event-driven, that state is always just a replay away: pipe the same event sequence through and you get exactly the same signals back.
Eleven proven scenarios — real, runnable, green
Each scenario below is a live demo in
tflo-react/validation/scenario-*.
React, Signals, RxJS, and XState can each reach the same output; the
difference is that they get there only by hand-writing the event-time logic
themselves. tflo expresses it once, declaratively.
All eleven are self-asserting — every variant (tflo, React, Signals, RxJS, XState) passes the same expectations.
Abandoned flow
Requirement: A started, not completed within 5 s of event-time.
// Abandoned flow — A started, not completed within 5 s of event-time.
// React / Signals / RxJS / XState reach the same output only by
// hand-writing the event-time logic themselves.
Pattern::new("abandoned_flow")
.when(|e| e.kind == "flow_started")
.not_then(|e| e.kind == "flow_completed")
.within(Duration::from_millis(5_000)) Temporal join
Requirement: join payment to order within a 3 s event-time window.
// Temporal join — payment lands in the engine;
// if an order with the same key arrived in the last 3 s (event-time), emit.
// RxJS can zip two streams, but event-time key-alignment is hand-rolled.
window_join_keyed(
payment_stream,
order_stream,
|p, o| p.order_id == o.id,
Duration::from_secs(3),
) Out-of-order windows
Requirement: a late event still lands in its correct event-time window.
// Out-of-order windows — late event still lands in its correct bucket.
// XState tracks "which state am I in," not "which event-time bucket owns this."
tumbling_window(
stream,
Duration::from_secs(60),
AllowedLateness::from_millis(2_000), // late events re-open the correct bucket
) Scenarios 4–11 — new, all green
Eight additional demos validate more advanced tflo shapes. Each lives in
tflo-react/validation/scenario-{4..11}-* with all four framework
variants plus the tflo reference. One-liner requirement and the tflo shape used:
| Scenario | Requirement (one-liner) | tflo shape |
|---|---|---|
| 4 — Rage click | ≥5 clicks within 2 s (repeat-window) | .when().then().times().within() |
| 5 — Card-testing | 2 payment fails with no success between (interior negation) | .when().notBetween().then().within() |
| 6 — Hesitation | ≥3 field edits within 5 s of focus (repeat-window) | .when().then().times().within() |
| 7 — Beaconed analytics | Out-of-order tumbling window over batched beacons | tumbling_window + AllowedLateness |
| 8 — Multi-device | Keyed event-time join across devices | window_join_keyed |
| 9 — Undo grace | Action committed unless undo arrives within 10 s (negation window) | .when().notThen().within() |
| 10 — Progressive disclosure | Repeated fails with no success between (interior negation) | .when().notBetween().then().within() |
| 11 — SLA / friction | No next step within 8 s of trigger (negation window) | .when().notThen().within() |
Tool boundaries — alternatives, not a ranking
Each tool has a native vocabulary. Using the right one means your code reads like the problem, not like a workaround:
| Tool | Native vocabulary |
|---|---|
| tflo | "when X, followed by Y, unless Z within T" — event-time pattern detection across an ordered sequence of events. |
| RxJS | "transform / combine / cancel async streams" — reactive operators over observables. |
| XState | "the UI is in one of these states; events transition between them" — finite-state machines and statecharts. |
| React state / Signals | "render the current value" — reactive rendering from a snapshot of application state. |
These are alternatives and composable layers, not a head-to-head ranking. RxJS and XState are excellent at what they do; tflo fills the specific gap of declarative event-time temporal logic that none of them model natively. You can use tflo inside an RxJS pipeline or feed its signals into an XState machine.
What tflo is good at, standalone
tflo's primary story is the one no JVM CEP engine can tell. If your need matches one of these, tflo runs alone:
- Edge gateways under 1 GB RAM. A 5–10 MB Rust binary in tens of MB of resident memory. ARM, x86_64, musl, glibc — all first-class. No JVM warmup, no GC pauses.
- Browser / WASM.
tflo-coreandtflo-opscompile clean forwasm32-unknown-unknown. Run signal detection live inside a dashboard, an extension, or a service worker. - Embedded in a Rust service. If your service is
already tonic, axum, or actix, tflo composes via
Iterator::tflo(...)inside the process you have. No sidecar, no IPC, no separate cluster. - Same code for batch and streaming.
Iterator::tflo(...)runs identically onVec<Event>for historical backfill and onStream<Event>for live. The detectors don't know or care which it is. - Typed missing values. tflo replaces
NaN-as-sentinel withComputed = Result<f64, Absent>— explicit reasons likeWarmingUp,DivideByZero,FilteredOut,DomainError. Downstream code branches on the reason, not on a single poisoned float. - Typed signals. Detectors emit
Signal<Mode, Payload>, cleanly factored: the mode says what happened (Rising,Entered,Runt,TooLong), the payload carries the data. - No deployment infrastructure. For single-process and Kafka-sharded shapes, you operate nothing beyond your existing service. No JobManager, no checkpoint cluster, no separate state backend.
How tflo composes with Flink, Esper, and Kafka Streams
Where users already operate a mature CEP or streaming engine, tflo fills the layers the host can't reach. Three concrete patterns:
1. tflo at the edge, mature engine in the center
A common shape: tflo runs on sensor gateways and in browsers, emitting typed
Signal events with already-conditioned data. Those signals flow into
Flink, Esper, or Kafka Streams for cross-source aggregation, enrichment, and storage.
[sensor gateways] [browser dashboards] [embedded services] │ │ │ └── tflo signal emission (typed Signal events) ──┐ │ [Kafka / NATS / MQTT] │ [Flink / Esper / Kafka Streams] │ [warehouse, alerts, user-facing apps]
What tflo contributes that the host engine cannot: typed Absent, typed
Signal, deployment in shapes the host can't reach (browsers, ARM
gateways, embedded services).
2. tflo as the per-key detector inside a host engine
The host engine owns watermarks, exactly-once, scaling, savepoints. tflo runs
inside a per-key processing slot (Flink KeyedProcessFunction,
Beam DoFn, Kafka Streams Processor) and provides the signal
logic plus typed-absence semantics that the host doesn't model natively.
[Flink job] │ ┌───────┴────────┐ │ KeyedProcess │ │ Function │ ← Flink owns watermarks, │ │ exactly-once, scaling │ ┌────────┐ │ │ │ tflo │ │ ← tflo owns signal logic, │ │ graph │ │ typed Absent, detector │ └────────┘ │ composition └────────────────┘
The integration crates (tflo-flink, tflo-beam,
tflo-kstreams) are designed and deferred until concrete user demand
surfaces — see interop backlog.
3. tflo as backfill / replay over historical data
The same detectors that run in production also run against archived events.
Iterator::tflo(...) works identically on Vec<Event> and
Stream<Event>, so a regression test, a what-if simulation, or a
historical re-emit uses the same code as live.
// Live (Tokio stream)
let signals: impl Stream<Item = Signal<_, _>> = live_stream.tflo(|t| { /* ... */ });
// Backfill (Vec) — same closure, same detectors, deterministic output
let signals: Vec<Signal<_, _>> = historical_events.into_iter()
.tflo(|t| { /* ... */ })
.collect(); Picking tflo
tflo is the right tool when
- Your deployment shape includes browsers, edge gateways, or embedded Rust services where a JVM is not an option.
- You want detector composition and signal emission in-process, not behind a network call.
- You need typed-
Absentcorrectness for the difference between "warming up" and "math domain error" — distinct cases thatNaNcannot represent. - You want batch and streaming with literally the same code.
- Your throughput doesn't justify the operational cost of a Flink / Beam / Esper cluster.
It probably isn't the right standalone tool when
- You need streaming SQL for analyst self-service.
- Your problem requires cross-region distributed semantics or multi-shard event-time joins. Host tflo inside Flink or Beam instead.
- You need a mature catalog of pre-built connectors (Debezium CDC, dozens of cloud sources). tflo provides Kafka, MQTT, Arrow / Parquet today; rich connector catalogs live in the host engines.
- Your team is Java-shop and standardizing on JVM tooling is more valuable than the deployment-shape benefits.
In both cases, the composition patterns above usually apply: tflo can still emit typed signals into your existing stack, or run inside it.
Boundaries
tflo deliberately doesn't do everything. The full list, with reasoning, is on the non-goals page — short version: streaming SQL, a JobManager, exactly-once via 2PC, a global watermark, savepoints, and processing-time mode are out. Each is a deliberate trade for the embeddable / WASM / edge primary story. Pattern composition itself isn't a non-goal — the closure DSL, custom nodes, timers, and CEL / Rhai / Rego let you compose multi-event patterns from primitives today.
Designed-but-deferred integrations live in the
interop backlog:
tflo-flink, tflo-beam, and tflo-kstreams for
hosting tflo inside a JVM streaming runtime. Promoted on concrete user demand.
Try it where the JVM can't reach
Quick Start runs on iterators and streams in five lines. The reference deployment glues MQTT, Kafka, Influx, and Parquet together.