How we made Plane 70x faster at enterprise scale
A behind-the-scenes look at the engineering changes that made Plane dramatically faster for the world's largest workspaces.
A behind-the-scenes look at the engineering changes that made Plane dramatically faster for the world's largest workspaces.


If your workspace holds a few thousand work items, Plane was already fast, and this release will not change your day much. It is for the other end of the curve: enterprise workspaces with millions of work items, tens of thousands of cycles, and tens of thousands of members, where every list, board, and search runs against millions of rows.
At that scale, the heaviest operations in Plane (grouping, filtering, sorting, counting, and searching across an entire workspace) are now 70× faster. Operations that took 10 to 20 seconds across varied actions, a table view, a cycle switch, a peak-hour board load, now resolve in a few hundred milliseconds.
The numbers
We benchmarked against a load-test environment seeded with twice the data of the largest build a customer could realistically reach. The database is a 2x-large multi-region RDS cluster spread across three availability zones. Every figure below comes from that environment.
What | Before | After | Measured on |
Board grouped by cycle | 20.6 s | 220 ms | Bench, 1M work items |
Board grouped by module | 19.6 s | 250 ms | Bench, 1M work items |
Board grouped by assignees (multi-value) | 8.3 s | 580 ms | Bench, 1M work items |
Work-item flat list | 12.3 s | 1.14 s unfiltered, 360 ms filtered | Bench, 1M work items |
"Has no assignee" class of filters | >12 s, aborting | 130–670 ms | Bench, default 4 MB |
Negating a popular value | >25 s, aborting | ~0.9 s | Bench, default |
Name sort (assignee, label, module) | ~8 s | ~milliseconds | Bench, materialized sort key |
Profile-stats endpoint | 2,676 ms, 40 MB | 460 ms, 690 KB | Load-test, 1.07M work items |
Teamspace cycles endpoint | ~2.4 s | ~150 ms | Large workspaces |
Open a work item | ~1.5 s | 172–199 ms | Prod, 100k+ items |
Project switch | 2–3 s freeze | instant, 14–29 ms nav | Prod, ~1M items, 20× throttle |
Work-item detail paint | 1–3 s skeleton | ~30 ms after route render | Prod |
Filter interaction | ~1.8M reactive writes | ~13k, 99% fewer | 100k+ items |
Spreadsheet first paint | ~2 s blank | ~317 ms, LCP 119 ms | Global all-work items view |
Search index on disk | 7.6 GB | 275 MB | |
Full search reindex | ~20 h | ~90 min | |
Browser memory, heavy workspace | above 1.5 GB | 500–750 MB | Approximate, observed in heavy use |
The dataset behind these numbers:
Column 1 | Column 2 |
Work items in the database | 8.5 million |
Work items in one workspace | 5 million, across 200 projects |
Work items in one project | 1 million |
Members in one workspace | 20,000 |
Cycles | 100,000 |
Modules | 50,000 |
Releases | 50,000 |
Views per user | 10,000 |
Work items were seeded realistically: up to 500 comments each, large descriptions, custom properties, workflows, states, and labels. One of our larger enterprise customers runs on the order of a few million work items and tens of thousands of cycles; we seeded past that, because customers migrating from Jira arrive with a decade or more of history and expect Plane to hold all of it. Most of that history is closed: in a mature workspace, the large majority of work items sit in Done states while a few hundred stay active.
What broke when we ran the benchmarks
None of this showed up in day-to-day use. It showed up when we stood the benchmark environment up, pointed load tests at the unoptimized platform. What follows is roughly the order we hit the problems, because each fix tended to expose the next one.
Problem 1: The meta endpoints were not paginated
Plane paginates its lists. What it did not paginate were the meta endpoints, the ones that feed dropdowns: states, assignees, cycles, modules. At a normal workspace those return a handful of rows and nobody notices. At enterprise scale a single workspace can carry tens of thousands of cycles and tens of thousands of members, and every one of them came down the wire on load.
Two symptoms, same root cause. The payloads were large, up to about 1.5 MB for a set of meta endpoints that, paginated, would be closer to 100 KB. And the responses were slow, because the server was serializing and shipping the entire set every time.
The fix had two parts. First, paginate the meta endpoints the way the list endpoints already were: cursor-based, with a server-side search so a dropdown queries for what the user is typing instead of downloading everything and filtering in the browser. Second, stop sending the full record where a lightweight subset will do. A cycle dropdown or a group header needs only a cycle's name and status, not its full detail; that only loads when someone opens the cycles list page. Most surfaces now fetch the light shape and pay for the full one only when a screen actually shows it.
The impact:
- Meta payload on load: about 1.5 MB to about 100 KB
- Paginating and trimming those payloads exposed the next problem.
Problem 2: the frontend rebuilt group columns from metadata it fetched separately
Grouping already happened on the backend; the board came back grouped. What the backend sent, though, was mostly IDs. To draw a board grouped by cycle, the frontend took the group IDs and resolved them into columns using metadata it had fetched separately, the same meta endpoints from Problem 1. Once those endpoints paginated, that separately fetched metadata was no longer fully in memory, so the resolve step had nothing to draw from. A grouped board showed a 1 to 3 second loading screen while the frontend fetched the names.
The fix is the sidecar: the backend co-ships the lightweight display data with the IDs in the same response. It scans the response it is about to return, collects exactly the referenced IDs, states, cycles, members, labels, and includes a deduplicated, ID-keyed block of their name-and-status metadata alongside the rows. The frontend stops fetching metadata to resolve columns and maps ID to name from what already arrived. It retains the block in a store, so the card renders on first paint with no follow-up request and the next page reuses whatever it already holds.
The impact:
- Grouped board load: 20.6 s to 220 ms (grouped by cycle, bench, 1M work items)
Problem 3: sorting and grouping had to read too many tables
With the display data resolved, the grouped query itself became the next bottleneck. Grouping and sorting by fields like state group or priority meant joining out to other tables on every request: go to the states table to resolve the group, come back, sort, repeat. The data was fully normalized, which is correct for writes and expensive for exactly this read pattern.
Sorting a million-issue project by priority meant resolving each issue's priority through a related table, then ordering the result. The join and the ordering, on every page load, were the cost.
We denormalized the two fields that drive most grouping and sorting, state group and priority, onto the issues table itself, maintained by a Postgres trigger that fires on write, and added composite indexes matched to the actual query pattern, meaning the exact combination of filter, group, and sort the board issues. A sort became a plain indexed read on one table.
The impact:
- Sort by a related field (assignee, label, module), 1M-issue project: about 8 s to a few milliseconds (bench, materialized sort key)
- Grouped board after denormalization: 20.6 s to 220 ms (grouped by cycle, bench, 1M work items)
Denormalized copies can drift, so this came with a backfill command and a reconcile job that catches rows written while triggers were disabled during bulk loads or restores.
We did consider the obvious alternative, more covering indexes, and rejected it. Across the fields we would have needed to index, the write amplification came to roughly 10× on the hottest write path in the product. A handful of denormalized columns with triggers bought the same reads for a fraction of the write cost.
Keeping the browser's memory flat: virtualization
Trimmer payloads and server-resolved columns fixed load time. Neither fixed what happens when someone scrolls. Rendering every row and every card that scrolls into a long board pushed browser memory past 1.5 GB on a heavy workspace.
Every long list now virtualizes: only the rows in view plus a small margin are mounted, and the rest exist as data, not DOM. Memory on a heavy workspace dropped from above 1.5 GB to roughly 500 to 750 MB, with fewer CPU spikes on page transitions.
Virtualization was one source of the memory drop, not the only one. Plane's frontend uses MobX for state and reactivity, and at scale the volume of reactive reads and writes was its own cost: recomputations firing far more often than the screen changed. Cutting that redundant reactive work across the platform, fewer observers, fewer writes per interaction, less recomputation on data that had not changed, is the other reason memory and CPU came down.
The kanban board needed more than row virtualization. A board grouped by a high-cardinality field can carry tens of thousands of columns, and each column scrolls its own rows, so virtualization has to run on two axes at once: paginate the columns, virtualize the rows within each column, and render a light placeholder card until a card actually scrolls into view, at which point the full card mounts. We built that as a custom component. We did try full two-dimensional virtualization first and rejected it, because the bookkeeping cost more than it saved; the column-plus-row hybrid is what we shipped.
Problem 4: search stored access control on every document
Search was a separate optimization with its own root cause. Access control lived inside the search index: every document carried its project's permission context, and every document in a project carries the same context, so a 10,000-issue project stored the same block 10,000 times. On real data that block was about 99 percent of what search stored, and any membership change reindexed every document in the project.
Index size was never a problem in years of normal operation. It became one when we tested workspaces with very large member counts, because the bottleneck was members per workspace, not work items.
We split access control out of the index. Permissions now resolve from the database first and apply to the search query itself, so the query is permission-aware and the index stores no permission data at any access level. An access change no longer touches the index.
The impact:
- Search index on disk: 7.6 GB to 275 MB
- Full reindex: about 20 hours and never completing, to about 90 minutes
The database was not the problem
One finding shaped the whole effort: on the slow endpoints there was no single database bottleneck to blame, outside of a few non-equality query patterns. The response time split across three places: the database, Python-side serialization, and a feature-flag lookup on every request. The work spread across all three rather than landing on one query fix.
The serialization piece had a clean fix. Turning tens of thousands of rows into model objects and passing them through the Django REST Framework (DRF) serializer was pure overhead on these read-heavy endpoints, so we bypassed DRF with a custom serializer. It is a standard technique we had not needed until this scale.
What changed, and what it means
This was load testing an enterprise-scale workspace, finding where the platform bent, and fixing each spot with the right approach for its scale: pagination reaching the endpoints it had not covered, grouping moved to where the data lives, hot read paths denormalized and indexed to their query pattern, rendering bounded to the viewport, and search access control moved out of the index.
The numbers at the top are the result. Boards and lists are no longer slower on enterprise scale.
Recommended for you



