The Sidebar That Ate Your RAM: How We Made Bruno Faster for Large API Collections
Every API client has a sidebar. Most of them get an easy deal: list the directories, list the files, done. Bruno's sidebar is needier. Each file is a request, a folder, or an environment. The UI shows the HTTP method and a display name, not get-user-by-id.bru. Before Bruno can draw a single row, it has to open the file, parse it, and work out what it is.
For a few hundred requests, nobody notices. Then the comments started rolling in:
Bruno is slow for large collections.
The app hangs when I load huge collections.

Fair. So we grabbed a ~3,000-request collection and started digging. This is Act 1 of that story: what made the sidebar eat RAM and CPU, what we tried, what we shipped, and what we had to undo before we could do it properly.
Table of Contents
Why Bruno's Sidebar Is Harder Than It Looks
In a normal file tree, the OS already knows the names. Bruno's collection tree is semantic. Folders can have display names that differ from the directory on disk. Requests need a method badge and a human-readable title. That means a parse before paint.
Mount path (before optimizations)
Disk
~3,000 .bru / .yml files
Main process
Ohm parse per file
IPC × N
1 event per file
Renderer
Re-render sidebar N times
I'd love to tell you we had a slick perf harness with flame graphs and dashboards. We did not. These were the early days, and our methodology was roughly: "The frontend is stuttering today. It shouldn't." Our most sophisticated tool was a performance mark at mount start and another at mount end. Science.
The Lineup: Four Suspects
We parsed every file, every single time
Open the app, parse 3,000 files. Close it, reopen it, parse 3,000 files again. Bruno had the memory of a goldfish.
The parser itself (Ohm + our grammar)
The Bru parser is built on Ohm. Ohm is a packrat parser: it backtracks freely and memoizes every position it tries, which is lovely for writing grammars and less lovely for raw speed. The version we use runs as an interpreter in plain JavaScript. Ohm's maintainers report that their Wasm-compiled engine in v18 parses about 20× faster.
Our grammar didn't help. Keys, values, and text blocks are matched one character at a time, each with a lookahead check. A chunky JSON body or a long script becomes thousands of tiny rule calls and tree nodes, and then we walk that tree again to build the request object. Multiply by 3,000.
One file, one IPC event
Electron apps live in two worlds: the main process (Node, files, the watcher) and the renderer (the UI). Every parsed file was shipped across that boundary as its own event. 3,000 files, 3,000 events, 3,000 re-renders. The sidebar jittered like it had had too much coffee.
A sneaky O(N²) addDepth
addDepth walks the collection tree and stamps a depth on every item so the sidebar knows how far to indent. Harmless O(N) on its own. Except the reducer re-ran it over the entire collection on every addFile event. Mount 3,000 files, walk the tree 3,000 times. Classic. It finally retired in #9190, when sidebar virtualization started computing depth while flattening the tree.
valuechar = ~(nl | tagend) any
textblock = textline (~tagend nl textline)*
Character-at-a-time rules in the Bru grammar: fine for correctness, expensive at scale.
Attempt #1: Just… Don't Parse?
The first idea was the lazy one, in every sense. If the sidebar only needs a name, method, and URL, why run the full parser at mount?
So we built lazy mount (#6689). On mount, a handful of regexes skimmed each file for its meta block, method, and URL, and the request went to the UI marked partial: true. The full Ohm parse happened only when you actually touched the request.
Lazy mount flow
Mount
regex skim → partial item
Sidebar draws
fast, incomplete data
On first use
ensureRequestLoaded → full Ohm parse
Mount got fast. Then reality set in.
- "Is this request loaded yet?" became a question everyone needed to ask: opening a tab, sending a request, the collection runner, global search, code generation, variable resolution, export, docs generation. Each one needed an
ensureRequestLoadedguard, and each one was a fresh place to get it wrong. - Regexes parsing a language that already has a real grammar. We had no test suite to prove those regexes handled every valid
.brufile out there, and the YAML side wasn't on par. Shipping that to thousands of users' collections felt like a great way to discover edge cases in production.
Sixteen commits and ~1,400 lines later, we put it on ice.
Attempt #2: Parse Once, Remember Forever (ish)
New plan: keep the full data, just stop re-parsing files that haven't changed (#6975).
Cache the parse
Parsed results went into IndexedDB, keyed by file with its mtime. When chokidar reports a file (which it does for every file on mount), we check the mtime. Unchanged → serve from cache. Changed → re-parse and update the cache.
That kicked off a friendly LRU-vs-TTL showdown in review. Siddharth wanted eviction so stale entries couldn't hog space. My pushback: IndexedDB quotas are huge, users can hit Clear Cache any time, and an LRU adds complexity for little gain. Worse, if a large collection needed more entries than the LRU limit, it would evict files we actually needed and we'd be back to parsing. A TTL was the more sensible option. In the end we shipped neither and kept the PR simple.
Batch the chatter
Instead of firing an IPC event per file, the main process collected parsed files and flushed them to the renderer every 200ms, in batches of up to 300. One re-render per batch instead of one per file. The sidebar finally put the coffee down.
Before: 1 IPC per file
file₂ → IPC → render
file₃ → IPC → render
… × 3,000
After: batch every 200ms (≤300)
[file₃₀₁…file₆₀₀] → IPC → render
… ~10 batches
Result: more than 10× faster parsing, a whole mount that finished noticeably quicker, the jitter gone, and a working prototype in two days. (Exact numbers? Remember the two performance marks? Yeah. Early days.)
We merged it. :tada:
Plot Twist: We Reverted It
Then we reverted it.
The code worked. The problem was that we couldn't prove it hadn't broken anything, because we had almost no behavioural tests for how mounting worked. As Anoop, our CEO, put it: "We cannot afford to have regressions in these critical parts of the app."
Fair again. So the next stretch went into locking down how mounting already behaved.
Locking Down Mount Behavior
In #7466 we built a fixture generator that spits out collections with self-describing names like F1, F1-F1, and F1-F1-R1, so a test can tell from the name alone where every item should land. On top of that sit Playwright E2E suites, each run against both .bru and YAML collections:
- Tree construction: folders nest correctly, requests land under the right parents, and parent-child relationships match the naming.
- Item sorting: items follow
seqwhen it's defined and fall back to alphabetical when it isn't. - Loading state: the collection mounts and shows up in the sidebar, and the spinner goes away.
- Method indicators: GET, POST, PUT, PATCH, and DELETE badges render correctly.
Not glamorous, but now any change to mounting has a safety net.
Self-describing fixture names
├── F1/
│ ├── F1-F1/
│ │ └── F1-F1-R1 ← request under nested folder
│ └── F1-R1
└── F2/
The Cache Was in the Wrong Room
So, did #6975 come back? Nope. And this is the fun part.
While writing tests against the existing pipeline, I stared at the data flow long enough to notice something silly. The cache lived in the renderer. Parsing happened in main. So we'd parse in main, ship the result to the renderer, cache it there, and on the next mount ship cached data back and forth across IPC again. We'd made parsing cheaper but kept the round trips over the very boundary we were trying to stop hammering.
Wrong: cache in renderer
→ IPC to renderer
→ cache in IndexedDB
→ next mount: IPC again to reuse cache
Right: cache next to the work
→ cache in main (durable store)
→ IPC only for UI updates
→ next mount: hit cache without re-shipping parse payloads
The real fix was to keep the cache next to the work, in the main process, with something sturdier than IndexedDB behind it. That meant a much bigger piece of infrastructure.
What's Next
Enter SQLite. But that's the next chapter.
Act 1 journey
Problem
Slow mounts @ 3k reqs
Lazy mount
Shelved (#6689)
Cache + batch
Merged, then reverted (#6975)
Mount E2E
Safety net (#7466)
Act 2
SQLite in main
If you're hitting slow mounts on a large collection today, you're not imagining it. The path from "sidebar that ate your RAM" to something sturdy enough to ship with confidence was longer than a two-day prototype. Act 2 is where the cache finally moves home.