Bruno API Client | Blog & News

The Sidebar That Ate Your RAM: How We Made Bruno Faster for Large API Collections

Written by Chirag Chandrashekhar | Nov 6, 2026

 

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

1

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.

2

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.

3

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.

4

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 ensureRequestLoaded guard, 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 .bru file 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
file₃ → IPC → render
… × 3,000

After: batch every 200ms (≤300)

[file₁…file₃₀₀] → IPC → render
[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 seq when 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

collection/
├── 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

Main parses
→ IPC to renderer
→ cache in IndexedDB
→ next mount: IPC again to reuse cache

Right: cache next to the work

Main parses
→ 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.