LeanZero Management: virtualising a 5,300-issue Gantt
Mihai Perdum
Author
10 min readAugust 19, 2026
Key takeaways
A 5,300-issue plan rendered 94,920 DOM nodes. Row windowing took it to 2,115, and first paint from about 2.1s to about 1.1s.
Row windowing does not touch the dependency arrows. They are drawn on a separate full-height SVG layer, and on an edge-dense plan they account for roughly 8,800 nodes on their own.
An edge between rows lo..hi is only visible when [lo,hi] intersects the window. That one predicate took rendered edges from 2,200 to 31.
Gate it above a row threshold. Below the gate the path must be unchanged, or every fixture you own quietly starts testing the windowed path instead of the one most plans take.
LeanZero Management shipped on the Atlassian Marketplace on 13 August. It is a Microsoft-Project-style Gantt for Jira Cloud: move one task and everything downstream re-schedules on your working calendar, buffers absorb the slip, the critical path recalculates on the drop, and every recalculated date is shown to you before it is written back to Jira.
This is not the launch post. This is about what happened when the Gantt was pointed at a plan with 5,300 issues in it, inside a Forge Custom UI iframe.
Every number below comes from a browser journey run against that plan. Where the record is ambiguous or the measurement is coarse, I say so rather than quoting it to three significant figures.
The measurement that started it
The plan was seeded to 5,300 issues in a throwaway project and a perf journey run against it. The result was not a crash — and that is worth being precise about, because "it fell over" would be a better story and it is not what happened:
The app survived. It indexed, it painted every one of the 5,300 bars, it stayed interactive, nothing went blank. What it did was scroll badly, and the reason was sitting in the node count: the Gantt rendered every row's divs whether or not that row was anywhere near the viewport, at roughly eighteen nodes a row.
That 42.2s at the top is the time to index 5,300 issues out of Jira and into storage. It is a backend cost in a different layer and nothing in this article touches it — I mention it only so the number is not mistaken for a rendering figure later.
The finding recorded at the time was that the Gantt renders all rows, with no virtualisation, "now empirically justified for large plans". That last clause is the point. "We should probably virtualise" is an opinion. Ninety-five thousand nodes is a reason.
Pass one: window the rows
The technique is the standard one — this is not the interesting part of the article, and I am not going to pretend it is novel.
Render only the slice of rows that can be seen, and hold the scroll height up with two empty spacer divs, one above the slice and one below:
The bottom spacer is the mirror of the top one, and it is the piece people forget — without it the scrollbar shrinks to the height of the window and the chart cannot be scrolled to the end:
That is the consumption side. The computation is the part worth showing, because three of its four lines exist for a reason that is not obvious:
jsx
1const updateRowWindow =useCallback(()=>{2const el = scrollRef.current;3if(!el)return;4const total = displayOrder.length;5const first =Math.floor((el.scrollTop-GANTT_HEADER_H)/ROW_H);6const last =Math.ceil((el.scrollTop+ el.clientHeight-GANTT_HEADER_H)/ROW_H);7const start =Math.max(0, first -ROW_WINDOW_BUFFER);8const end =Math.min(total, last +ROW_WINDOW_BUFFER);9setRowWindow((prev)=>(prev.start=== start && prev.end=== end ? prev :{ start, end }));10},[displayOrder.length]);
GANTT_HEADER_H is subtracted from scrollTop. The timescale header is sticky and sits inside the same scroll container, so raw scrollTop is offset from the first row by exactly its height. Miss this and every row is off by one or two for the whole length of the chart — the arrows still land correctly, because they use their own index maths, and you get the confusing symptom of rows and connectors disagreeing.
ROW_WINDOW_BUFFER is eight rows on each side. It is overscan: rows are mounted slightly before they are needed, so a fast scroll does not expose blank space in the frame between the scroll event and the re-render. Eight is not a derived number; it is comfortably more than one frame's worth of wheel movement at the row height this chart uses.
The setState returns prev when nothing changed. This one matters more than it looks. The scroll handler fires continuously, but the window only changes when the scroll crosses a row boundary — perhaps once every twenty events. Returning the previous object keeps the reference identical, React bails out, and the re-render does not happen. Without that guard you re-render the entire Gantt on every scroll event and hand back a good part of what virtualisation just won.
Two things drive that function. The existing scroll handler calls it, throttled to at most once a frame:
and a ResizeObserver recomputes it when the container changes size:
jsx
1useEffect(()=>{2if(!virtualizeRows)return;3updateRowWindow();4const el = scrollRef.current;5if(!el ||typeofResizeObserver==='undefined')return;6const ro =newResizeObserver(()=>updateRowWindow());7 ro.observe(el);8return()=> ro.disconnect();9},[virtualizeRows, updateRowWindow]);
The observer is not optional in a Custom UI, and I will come back to why.
This whole approach depends on one precondition that is easy to skip past: every row is the same fixed height, ROW_H. That is what makes rowWindow.start * ROW_H a correct spacer height and i * ROW_H a correct row position. Variable row heights turn this into a much harder problem involving measurement caches, and none of the arithmetic here survives that change.
Measured on the same 5,300-issue plan:
before
after
DOM nodes
94,920
2,115
Gantt bars in the DOM
5,300
27
first paint
~2.1s
~1.1s
10x scroll
~4.6s
~1.6s
5 rows × 3 columnsHeader row enabled
About a forty-five-fold reduction in nodes.
The line that keeps the tests honest
virtualizeRows is a gate. Above 150 rows the window applies. At or below it the code takes the path it always did: no slice, no spacers, i is localI.
I would argue for that in review. Every visual fixture and every frontend test in the suite runs on plans well under 150 rows. If virtualisation applied to them too, all of those tests would quietly start exercising the windowed path, and the unwindowed path would have no coverage at all. A performance change that silently rewrites what your tests are testing has taken more than it gave.
I do not have a distribution of real plan sizes to tell you which path most customers actually take — the app had no install base when this was measured, so any claim I made about that would be invention.
Pass two: the layer that windowing does not reach
Here is the part worth reading, and I want to be accurate about how it came up, because the tidy version would be a lie.
It was not a surprise. When row virtualisation shipped, the limitation was written down the same day, in the same record:
large-plan-WITH-many-dependencies not live-tested (LZPP tasks are independent) — the connector layer is index-math so it's safe by construction, but a 1000s-of-EDGES plan would still draw all SVG paths (separate future concern)
So the 94,920 → 2,115 measurement was taken on a bed with zero dependency links. The row win is real and that is genuinely what it measures, but it says nothing at all about a plan with arrows on it. To find out, a second bed had to be built.
The dependency arrows are drawn on a separate, full-height SVG layer that sits over the rows. It had never heard of the row window. For every dependency link it emitted a <g> containing three paths — a casing, the visible line, and an invisible hit target — four DOM nodes per link, regardless of whether either endpoint was within a thousand rows of the viewport.
The second bed seeded 2,199 Blocks links, deliberately shaped: 1,999 adjacent-row chain links, which are the common case and almost all cullable, plus 200 long-range links each spanning 400 rows — roughly ten viewports, and the ones that must keep rendering. Molding the data that way is the whole reason the test is worth anything, and it is the lesson I would take from this if I took only one: a windowing optimisation cannot be tested on data that does not exercise the window.
On that dense plan, before culling:
text
1edges rendered in the DOM 2,200
2DOM nodes 10,917
310x wheel scroll ~2.0s
Those 2,200 edges are 8,800 nodes on their own — which is exactly the gap between the 10,917 total here and the 2,115 the rows had come down to. Four nodes an edge, and the arithmetic closes.
The predicate
The fix is one predicate, and it rests on a piece of reasoning that is obvious once written down and easy to get wrong in code:
A curve drawn between row lo and row hi is visible only when the interval [lo, hi] intersects the visible window.
Not "when one of its endpoints is visible". That is the tempting version and it is wrong: it culls a dependency running from row 200 to row 600 whenever the user is scrolled to row 400, so the reader watches a long arrow vanish exactly when it is telling them something.
jsx
1// Edge windowing (large plans only): skip an edge whose row-span is entirely2// outside the visible window. A curve between rows lo..hi only crosses the3// viewport when [lo,hi] intersects the window, so this culls off-screen4// (mostly short) edges while KEEPING long edges that pass through — and it5// skips the getBarPos/connectorFan/depCurve compute below, not just the DOM.6if(virtualizeRows &&(Math.max(i, si)< rowWindow.start||Math.min(i, si)> rowWindow.end))returnnull;
i and si are the row indices of the two endpoints. Math.max(...) < window.start means the whole edge sits above the window; Math.min(...) > window.end means it sits entirely below. Everything else draws, including every long edge that crosses the viewport without either end being inside it.
Three details worth copying:
It returns before the geometry. The return null sits above the getBarPos / connectorFan / depCurve calls, so a culled edge costs no path maths either. Culling only the DOM would have left the per-frame CPU cost intact, and on a scroll handler that is most of what you were trying to fix.
It reuses the row window as it is. Same gate, same rowWindow — including the eight-row overscan the row window already carries, which is why edges do not pop in at the boundary either. The edge test adds no buffer of its own. One source of truth for "what is visible" matters here because two windows updated on two code paths drift out of phase during a fast scroll, and the visible symptom is arrows flickering against rows that are not moving.
The same predicate is applied twice. Parent-guide lines are a second full-height overlay with exactly the same problem, and they are culled the same way. If you copy only the dependency-edge cull you will leave half the nodes on the chart.
The result on the same dense plan:
before
after
edges rendered
2,200
31
DOM nodes
10,917
2,241
10x scroll
~2.0s
~1.4s
4 rows × 3 columnsHeader row enabled
About seventy-one times fewer edges, and the long links still draw whenever they cross the viewport — which is why they were seeded. A bed of only adjacent-chain links would have made the naive either-endpoint predicate look perfect.
Measuring it on your own app
None of the above is worth much if you cannot tell whether it helped, and the trap here is measuring the wrong thing. A frame-rate figure from your laptop scrolling a demo plan tells you almost nothing, because the number that hurts is not frames — it is how much document the browser is being asked to maintain.
The cheapest useful metric is the node count, and you can take it from the browser console on a real plan without any tooling at all:
js
1document.querySelectorAll('*').length
Run that on your largest plan before you change anything, and write it down. That single number is what justified this entire piece of work, and it is the one that moved by forty-five times. If it is in the tens of thousands you have a problem whether or not the chart currently feels slow, because you are one slower machine away from it feeling slow for a customer.
Then count the things you actually render, which is more diagnostic than the total because it tells you which layer is at fault:
Those two are how the split between the row problem and the arrow problem became visible in the first place. A plan where the bar count is small and the node count is still large is a plan where something other than rows is doing the damage — which is exactly the state the chart was in between the two passes described above.
The last measurement is the one people skip: take the counts again after scrolling. A window that does not track the scroll looks identical to a working one on first paint, and only diverges once the user moves. The journey that measured this app records both a barsAfterScroll and an edgesAfterScroll for precisely that reason — 27 bars on load and 37 after scrolling is a window that is tracking; 27 and 27 would mean it had frozen, and 5,300 either way would mean it never engaged.
Two warnings about the numbers you get. First, seed the data to exercise the thing you are testing: a plan of independent tasks will show zero dependency edges and tell you nothing at all about the arrow layer, which is why the second bed here had to be built by hand. Second, be honest about your instrument. The timings in this article come from a browser journey that scrolls ten times and measures wall-clock around the loop, so they include the harness's own waiting. They are directionally right and reproducible on the same rig, and they are not profiler output. If you need to defend a latency number to somebody, measure it with PerformanceObserver inside the frame instead of timing a script from outside it.
The thing that did not break
When you stop rendering rows under a layer that draws connectors between them, the obvious fear is that the arrows drift — that they land half a row out, or point into empty space, once the rows they reference are gone from the DOM.
They did not, because of a decision that predates all of this: the arrow layer computes position from row-index arithmetic on a full-height coordinate space. Row 4,000 is at 4000 * ROW_H whether or not row 4,000 is mounted. The layer never measures a row element and never calls getBoundingClientRect on one.
That property is what made row virtualisation a contained change rather than a rewrite. Had the connector layer been measuring DOM elements — a perfectly reasonable way to write it — removing rows would have broken every arrow on the chart.
It generalises: keep overlay geometry in model coordinates, not measured coordinates. You do not notice the benefit until the day you need to stop rendering things, and then it is the difference between one commit and a much larger one.
The same discipline shows up elsewhere in this app. The scheduling engine has to agree with itself across two implementations, which is its own category of problem — I wrote about the off-by-one a naive test suite will miss separately. And if you are making a Custom UI look native in both themes while you are at it, dark mode in Forge Custom UI covers ground I will not repeat.
What this does not do
Virtualised rows are invisible to Ctrl+F and to assistive technology. This is the honest cost of the whole technique and it is worth stating first. A row that is not mounted cannot be found by the browser's own find-in-page, and it is not in the accessibility tree, so a screen reader cannot announce a total or a position. The standard mitigation is to tell assistive technology what the DOM no longer says: aria-rowcount on the grid with the true total, and aria-rowindex on each rendered row with its real index rather than its position in the window. I have not added those. On a planning tool used to find one task among thousands, that is a real gap and not a theoretical one — if you ship this, ship that with it, and be aware that find-in-page has no equivalent fix. Your own search UI becomes load-bearing the moment the rows stop being in the document.
Indexing is still slow. Getting 5,300 issues out of Jira takes about 42 seconds. Different layer, untouched by any of this.
5,300 issues is the number I tested. Not 50,000. The row window is O(visible), but plenty of surrounding work — building displayOrder, the parent rollup, the dependency index — is still O(n) per render, and I do not know where that becomes the wall.
The threshold is a judgement. 150 rows is where the gate sits. I did not bisect for the crossover point; it is comfortably above the largest fixture and comfortably below where the DOM was measurably hurting.
The timings are coarse. They come from a browser journey that scrolls ten times and measures wall-clock, not from a profiler, and the repo's two records of the dense-plan scroll figure disagree with each other by about fifty milliseconds. They are directional. Treating them as frame-time data would be reading more into them than they contain.
Why this bites harder in a Forge app
A Custom UI runs in an iframe you do not own, sized by the host, sharing a main thread with Jira. Two things follow.
The first is a budget problem: the room you get before an app feels slow is smaller than on your own site, and the failure mode is worse, because it does not read as "this Gantt is slow", it reads as "Jira is slow".
The second is concrete, and it is why the ResizeObserver above is load-bearing rather than defensive.
Row windowing needs a scroll container whose height you know, because scrollTop and clientHeight have to be read from a real element. On an ordinary page you would reach for the viewport and be done. Inside a Custom UI you cannot: your document is an iframe whose height is chosen by the host, not by you, and it changes for reasons that never reach your code as anything you can subscribe to. The user collapses the Jira sidebar. They resize the browser. The app is asked to grow because something above it in the host page changed. Every one of those resizes the frame around you, and none of them fires a resize event you would naturally be listening for, because from the iframe's point of view the window did not change — the element did.
If the window is only recomputed on scroll, all of those leave a stale clientHeight behind, and the symptom is a chart that renders a screen and a half of rows into a container that is now three screens tall, with empty space below the last row until the user scrolls to shake it loose. Observing the container directly is what closes that gap, and it costs one effect.
The general form of the lesson: inside a Custom UI, treat your own container as the source of truth for size, never the viewport, because the viewport belongs to somebody else.
If you want to see the whole thing with the scheduling attached, LeanZero Management is on the Marketplace — the Gantt in this article is its Gantt, measured on the 13 July build, and the dependency behaviour it exists to serve is described here.
If you are about to do this to your own chart, the one thing I would carry over is not the row window. It is going and looking at whatever else you draw on a full-height layer once the rows are gone, because that is where the DOM you just saved is hiding.
forge lint cannot see your request helper: the Jira scopes it never checks