A few weeks ago I promised a technical write-up. Here it is.
The two questions I get most are: how do you render tens of thousands of Jira issues in under 10ms? And how does the AI figure out how to group things? They're related, and the answer to both starts with the same principle — do less.
The rendering problem
Jira backlogs are large. A mature project might have 50,000 issues. Naively rendering 50,000 nodes in a browser canvas is slow, and no amount of JavaScript optimisation is going to change that.
The insight is that you never need to render all of them.
The Sierpiński carpet is a fractal — it's recursive by definition. At any given moment you're looking at one level of the hierarchy, with the levels above and below rendered at progressively lower fidelity. We cap this at MAX_DEPTH + 2 levels visible at once. The result: no matter how large your backlog, a single view renders at most 584 cells. We measured layout time at ~1.4ms for a 1,000-issue project and ~1.8ms for a 10,000-issue one. It's essentially flat.
Why Rust and WASM
The geometry work — computing where every node goes, what size it should be, what fidelity level it gets — is pure arithmetic with tight loops. It's exactly the kind of work JavaScript is not well suited to, and exactly what Rust is.
We compile the layout core to WebAssembly. It runs in the browser, but it runs at near-native speed. The Rust core produces a flat buffer of RenderedNode structs — position, size, depth, status, fidelity — and the JavaScript layer is a dumb consumer of that buffer. It reads the numbers and draws rectangles. No logic lives in the rendering layer.
The canvas itself is four stacked layers: geometry, state (colours and fill), focus (the working level highlight), and presence (collaborator indicators). Only the layers that need to change repaint. A status update on one issue repaints the state layer. Navigation repaints the focus layer. The geometry layer repaints only on resize.
This separation is what keeps navigation instant. Drilling into a node is a WASM call and a canvas repaint, not a re-fetch or a DOM reconciliation.
How the AI groups your backlog
The AI grouping was the part I was most nervous about — not technically, but economically. LLM calls cost money, and a Jira backlog with 5,000 issues could get expensive fast if you sent every issue to a model.
The answer was deterministic first.
Before we touch an LLM we build as much structure as we can from what Jira already knows: existing parent/child links, epics, components, fix versions, labels, issue type. For most backlogs this resolves the majority of issues at zero inference cost.
Only then do we look at what's left — the overflow buckets where a parent has more than 8 children, and the Unsorted pile of issues that don't fit anywhere. Those go to the model, batched per bucket, not per issue. One call covers an entire overflow group. The model receives the issue keys and summaries, and returns a named partition — at most 8 thematic groups with a one-line rationale for each.
If a returned group still has more than 8 members, we recurse. The constraint is enforced all the way down.
We measured this at around $0.24 per 1,000 LLM-resolved issues, which sounds small and is small — the cost is dominated by the fixed overhead of the prompt and tool schema per call, not by the number of issues. It scales with the number of overflow buckets, not the size of the backlog, which rewards the deterministic-first approach.
One more thing worth knowing: any group you rename, move or adjust is marked as yours. A rebuild will work around your edits, never overwrite them.
The combination — a WASM geometry core that never renders more than it needs to, and an AI layer that only runs where deterministic logic falls short — is what makes Teppe feel fast and cheap to operate at the same time.
If any of this is interesting and you want to go deeper, feel free to reach out.
Email: info@teppe.io
Luke
Top comments (2)
The deterministic-first split before touching an LLM is the right call, and the $0.24 per 1,000 number backs it up.
One thing I'm curious about on the overflow-bucket path: once a bucket's groups exist and a human has renamed or moved one, what happens as the backlog keeps growing into that same bucket? Does new growth get classified against the existing named groups (model just slots new issues into what's already there), or does crossing the 8-member threshold again trigger a rebuild of the whole bucket, with your edits restored afterward? If it's the latter, you're paying the full per-bucket cost again every time growth tips it back over 8, which seems to work against the whole point of batching per-bucket instead of per-issue in the first place. Would be curious which one it actually is in practice.
Great question — this is exactly the seam where per-bucket batching either pays off or doesn't, so it's worth being precise.
Short version: it's closer to your second option (re-cluster the bucket), not the first (slot new issues into the existing named groups) — but with three guards that stop it becoming the per-issue churn you're alluding too.
What actually happens as issues grow into a bucket that's already been grouped:
A new issue first lands in its deterministic bucket (component/type/etc.). That placement is free — no model. The AI grouping only matters if that bucket is over 8.
We don't slot the new issue into an existing named group. When an issue arrives in a bucket that already has groups, we flatten the bucket back to a flat member list and re-group it as a whole. There's deliberately no "classify this one issue against the prior groups" path — incremental slotting against a stale grouping is how you accumulate drift and end up with named groups that no longer match their contents. So yes, the unit of work is the whole bucket.
Three guards keep that from meaning "re-pay every time it tips over 8":
Membership-keyed cache — a bucket is keyed by a hash of its exact member set. If that set's been grouped before, it's a cache hit, no model call. You only pay when the contents genuinely changed.
A per-run budget — re-grouping is capped per sync pass; once it's spent, further changed buckets fall back to free deterministic chunking (still ≤8) and get a proper grouping on a later pass. A burst of growth can't run the bill up.
Change-gated — it only fires when a bucket's membership actually changes. A status change, or an issue already in the right bucket, triggers nothing.
So the cost you re-pay is one bucket, not the project — which is the actual point of per-bucket batching: the blast radius of growth is a single bucket's re-group, cached and budgeted, rather than a per-issue stream of calls.
On "with your edits restored afterward" — I'd reframe that part. Renames, moves, and groups you create aren't stored in the grouped tree at all; they're a separate overlay re-applied on every read. A re-group (from growth or a manual rebuild) doesn't "restore" your edits — it never touches them; they sit on top. The one caveat: an edit only re-applies if its target still exists — if you'd moved an issue into a group a later re-group dissolved, that move is safely dropped (the issue falls back to its automatic spot) rather than dangling.