Performance

This documents profiles performance and documents R&D decisions, mostly around handling large file capacity.

This project has 3 pillars of performance:

  1. Network payload size
  2. How responsive the editor is
  3. Capacity (how large of files we can load)

At the time of writing, the network payload size is ~4KB and the responsiveness is <20ms.

We will see how far we can push the capacity limit. Eventually, but in the distant edge cases, responsiveness will degrade. In fact, we may even see issues of correctness.

We want to profile the behavior at that boundary, perhaps study solutions, which informs our design decision, but not necessarily dictate it. In simpler terms, we may discover that it's possible to handle mega large files with some tweaks but the trade-off to the vast majority of usage is not worth optimizing for this long tail.

All tests done on 18GB MacBook Air. Key is that capacity scales O(n) of RAM with low overhead and no bottleneck. Compare vs libraries that mount whole file to DOM being unresponsive by O(100K) lines. VDOM has other scaling issues.

Basic Load Test with Naive File Loader

This is a load test with a "naive loader" which reads the file into the heap and splits on "\n".

30 lines

Took 0.20 millis to scroll viewport with 30 lines. That's 5000.00 FPS.
Took 1.90 millis to insert with 30 lines. That's 526.32 FPS.
Took 1.60 millis to insert new line with 30 lines. That's 625.00 FPS.
Took 1.50 millis to delete line with 29 lines. That's 666.67 FPS.
Took 1.40 millis to delete character with 30 lines. That's 714.29 FPS.

500k lines (jump to viewport 250000)

Took 0.30 millis to scroll viewport with 500001 lines. That's 3333.33 FPS.
Took 1.30 millis to insert with 500001 lines. That's 769.23 FPS.
Took 2.30 millis to insert new line with 500003 lines. That's 434.78 FPS.
Took 2.50 millis to delete line with 500002 lines. That's 400 FPS.
Took 1.10 millis to delete character with 500002 lines. That's 909.09 FPS.

5 million lines (100 MB, jump to viewport 2,999,999)

Took 0.70 millis to scroll viewport with 5000001 lines. That's 1428.57 FPS.
Took 1.30 millis to insert with 5000000 lines. That's 769.23 FPS.
Took 6.40 millis to insert new line with 5000001 lines. That's 156.25 FPS.
Took 1.20 millis to delete character with 5000001 lines. That's 833.33 FPS.
Took 5.60 millis to delete line with 5000000 lines. That's 178.57 FPS.

10 million lines (280 MB)

Took 0.70 millis to scroll viewport with 10000001 lines. That's 1428.57 FPS.
Took 1.30 millis to insert with 10000001 lines. That's 769.23 FPS.
Took 28.40 millis to insert new line with 10000002 lines. That's 35.21 FPS.
Took 1.30 millis to delete character with 10000001 lines. That's 769.23 FPS.
Took 4.60 millis to delete line with 10000001 lines. That's 217.39 FPS.

It took ~1 second to load the 100MB 10 million line file with the naive loader. Same as with VSCode.

We stop here for the naive loader because its limit is somewhere between the 10 million and 20 million line files. This is likely due to the string length and/or the ability to split on "\n" for such a large string.

Test Files

To avoid Git hosting excessively large files, resources directory only contains up to 200k file. The files were generated as follows:

seq 1 5000000 | awk '{print "This is line number " $1}' > 5_million_lines.txt

This is not representative of real files because there is a lot of regularity. However, it makes it convenient to do apples-to-apples comparison.

Chunked File Loading

No improvement: Sequentially adding lines

We use the FileLoader extension's internal appendLines method and send in 100k lines at a time.

const fileLines = fileSourceTextString.split('\n');
primary.Model.lines = [];
const BATCH_SIZE = 100_000;
for (let i = 0; i < fileLines.length; i += BATCH_SIZE) {
  const batch = fileLines.slice(i, i + BATCH_SIZE);
  Model.lines.push(...batch);
}

This doesn't work and still fails on 20 million line files. The bottleneck seems to be on the source file being in memory.

~70 million: File.slice to read byte chunks

We avoid converting the file to String right away. The unit of iteration are in terms of bytes (1MB at a time) rather than lines and strings and delimiters.

It works!

[Chunked] Loaded 20,000,000 lines in 1348.00ms // 542 MiB
[Chunked] Loaded 50,000,001 lines in 5351.40ms // 1516 MiB
[Chunked] Loaded 70,000,001 lines in 10449.20ms // 1925 MiB

The browser tab crashes at 72 million on a 100 million line file.

Comparison: Naive vs Chunked

LinesNaiveChunked
200,00029.30ms30.30ms
500,00041.60ms44.80ms
1,000,00068.20ms84.10ms
5,000,000295.60ms293.30ms
10,000,000821.70ms678.70ms
20,000,000fails1348.00ms
50,000,001fails5351.40ms
70,000,001fails10449.20ms

The chunked file byte reader implementation isn't too complex so it should be preferred for robustness.

Memory Usage

At 70 million lines, consumed 3660MB of heap space, near Chrome's 4GB tab limit. Running Chrome with --js-flags="--max-old-space-size=8192" does not seem to increase the tab limit.

Chunk Size Experiments

Larger chunk sizes are more performant but risk stack overflow when pushing to Model.lines. 1MB is the sweet spot.

Lines256KB512KB1MB2MB
10M872.80ms685.40ms678.70ms640.30ms
20M1768.60ms1394.80ms1348.00ms1316.30ms
50M6921.20ms4838.50ms5351.40ms5461.80ms

Stream API Loader

Stream API is the most modern approach. Faster after 1 million lines.

[Stream] Loaded 5,000,000 lines in 249.60ms
[Stream] Loaded 10,000,000 lines in 494.00ms
[Stream] Loaded 20,000,000 lines in 1016.80ms
[Stream] Loaded 50,000,001 lines in 4380.10ms
[Stream] Loaded 70,000,001 lines in 12008.30ms

Crashes at around 75 million after adding UI yield logic.

Materializing Sliced Strings

After inspecting heap, [sliced strings] accounted for ~100MB redundancy. Materializing strings gives 5-10% capacity improvement:

// Before
Model.lines.push(...slicedLines);
// Heap: 3650MB

// After - force materialization
const materializedLines = slicedLines.map(line => Array.from(line).join(''));
Model.lines.push(...materializedLines);
// Heap peak: 3300MB, after: 3150MB

Force GC

This hack gets the GC to kick in earlier:

const _ = new Array(100000);

Unstable after 70 million LOC, but would hit 88 million often.

Ultra-High-Capacity Mode

This mode involves incremental compression. The compressed data lives in native C++ memory rather than the heap.

The editor breezed past the previous 88 million cap and reached 1+ billion lines while consuming ~2.5GB of RAM. This is contingent on the specific file and its compression profile.

Ideas


Buffer data structure

Buffers are the darkhorses text editor implementations. They map 1:1 with the problem domain but are infeasible in practice as text editing operations constantly involve lines being reindexed. The pantheon of native text editors require a combination of complex data structures (VSCode, whle running in Electron, still reached for a Piecetree: https://code.visualstudio.com/blogs/2018/03/23/text-buffer-reimplementation). The piecetree is implementation alone is 10x buffee's entire footprint. Buffee owes this to V8 arrays not being real arrays and hyperoptimized by V8 magic. We can program against an intuitive model and remain compact, while getting O(1) cursor/line-wise editor operations

VSCode's Buffer Management

In 2022, VSCode would choke on 10^7 LoC (50MB) files, becoming unresponsive for up to a minute while loading them into memory. VSCode had already adopted a Piece Table combined with a balanced tree (see: https://code.visualstudio.com/blogs/2018/03/23/text-buffer-reimplementation). By September 2025, VSCode appears to have fixed this bottleneck by detecting large files, short-circuiting preprocessing, and defaulting to plaintext editing. The issue may have been in syntax highlighting rather than buffer management—`vim` proves it's feasible to syntax highlight much larger files.

Rendering strategy

Beyond V8 arrays, buffee's performance is achieved by "culling" techniques borrowed from game programming. That is, we only render the viewport.

The rendering logic is low-level and surgical on the small DOM footprint which we do maintain. This is opposed to the zeitgeist of webdev which involves maintaining a VDOM, diffing it, then patching it to the real DOM. The smallest yet still trustworthy DOM diffing libraries are larger than buffee in its entirety.