Why opening a big file took 28 seconds — and how we got it to 2
August 6, 2026 · performance · profiling · architecture
Opening a file in a text editor means more than reading bytes off disk. Before the first line appears on screen, the editor has to split those bytes into lines, find the information syntax highlighting needs, and lay each line out so it can be drawn.
It’s a path any editor ends up walking — but each stage hides a performance problem that only shows up on a big file.
Crimson Editor once took 28 seconds to open a 100 MB file of about 900,000 lines, and 13 to save it. After finding and fixing the bottleneck in each stage, the same file now opens in 2.2 seconds and saves in 0.14.
Let’s walk through, in order, where the time went, why the first implementation was slow, and how each stage was improved.
The three jobs an editor does to open a file
Opening a file breaks into three stages.
- Read — read the bytes off disk, decode them, and split them into lines.
- Tokenize — find the keywords, strings, comments, and numbers on each line that syntax highlighting needs.
- Lay out — measure the width of the words and characters and place each line into screen rows.
Timed on the 100 MB file, before and after:
| stage | before | after | factor |
|---|---|---|---|
| 1. read + split into lines | 3,673 ms | 752 ms | 4.9× |
| 2. tokenize for highlighting | 6,149 ms | 557 ms | 11× |
| 3. lay out the screen | 17,417 ms | 49 ms | 355× |
| open (wall clock) | 28.4 s | 2.2 s | 13× |
The three problems look unrelated, but the same cause sits under all of them.
Only a fraction of the data is needed at any one time, yet each stage was doing work proportional to the whole file. Every one of these fixes, in the end, is about not processing the whole file when you don’t have to.
Stage 1 — Read the bytes into lines
The first stage reads bytes from the file and splits them into lines.
The simplest implementation reads a small buffer, finds the first line break, returns that one line, and repeats from there. Crimson Editor worked this way for years, with a 512-byte buffer.
The problem was that, to resume from the next line, it moved the file position back to the end of that line. On a 900,000-line file that’s about 900,000 reads and about 900,000 seeks — and most bytes end up read several times over.
Saving was similar. Writing one line at a time meant one write call per line, about 900,000 system calls.
The fix was to work in blocks rather than lines.
Read 64 KiB at a time and handle every line inside the block at once; carry only the partial line straddling the boundary into the next block. On save, buffer up to 64 KiB and flush when it’s full.
With that change, reading got 4.9× faster and saving 96×.
Stage 2 — Tokenize each line
The second stage finds what syntax highlighting needs: where the keywords, strings, comments, and numbers are on each line.
It runs a small state machine over every character. With 900,000 lines to scan, this character scan looked like it had to be the biggest bottleneck.
Profiling said otherwise.
Crimson Editor updated a progress bar every 20 lines as it tokenized — about 45,000 redraws on a 900,000-line file.
Each update built an offscreen bitmap, drew the border and the percentage, copied it to the screen, and released the objects again. The bar has only 101 possible appearances (0% to 100%), yet each was drawn hundreds of times over.
In the end, most of those six seconds went not into finding tokens but into drawing the progress bar.
The lesson is simple.
Redrawing the screen more often than a person can tell apart is wasted work.
The fix was just as simple: inside the progress bar, compare the current percentage with the previous one and skip the draw if it hasn’t changed. Because the check lives in the progress bar itself, it applies to every caller at once.
The character scan improved too. Instead of calling a function to ask whether each character is a letter, a digit, or a delimiter, it now reads the answer from a lookup table built once per language.
That alone was worth about 12%, but small next to removing the progress bar’s needless redraws.
Stage 3 — Lay the lines out on screen
The third stage places each line onto the actual screen.
The editor asks the operating system for the pixel width of the words and characters, and uses that to arrange the text into screen rows.
The old Crimson Editor’s biggest problem was doing this for the whole file. It laid out all 900,000 lines, though a user can see only about 50 at a time.
The layout of the other 899,950 lines was computed and then thrown away unused.
The fix was to lay out a line only when it’s actually shown on screen.
When the file opens, the row list is filled with empty placeholders and returned right away. Each row’s layout is then done the first time it’s drawn.
That one change cut layout time from 17.4 seconds to 49 milliseconds — about 355×.
Lazy layout did leave one problem to solve: syntax state that carries across many lines.
Whether line 5,000 is inside a block comment (/* … */), for instance, depends on everything from line 1 to line 4,999. But with lazy layout, those earlier lines may not have been laid out yet, because they haven’t been shown.
Fortunately, deciding that needs no character or word widths. Whether a line is inside a block comment is already part of the tokens produced in stage 2.
So we added a separate, lightweight pass. It records only one thing per row — whether that row is inside a comment — as a flag. It measures no character widths and lays out nothing.
Most of the 49 milliseconds that remain go into this pass.
Word wrap was the exception
Lazy layout is especially effective when word wrap is off. Without wrap, one logical line is one screen row, so the total row count the scroll bar needs is known without laying anything out.
Turn word wrap on and that changes.
A line can wrap into several rows depending on the window width, so the total number of screen rows can’t be known without actually laying it out. Because of this, with word wrap on, layout still took 12 seconds.
The biggest cost was measuring text width.
Sizing the wrapped Korean words called an OS function about 3.6 million times, each call finding a fallback font and shaping the characters.
But a source file usually uses only a few thousand distinct characters. And in this draw path, a character’s width didn’t depend on its neighbours, so a word’s width could be computed as the sum of its characters’ widths.
So we measured each character’s width once and stored it in a cache. When the same character came up again, we used the cached value instead of asking the OS.
OS calls dropped from about 3.6 million to roughly the number of distinct characters, and word-wrap layout fell from 12.3 seconds to 662 milliseconds.
This works only because Crimson Editor’s current text draw path uses no kerning and no cross-character shaping.
Kerning is what nudges a pair like AV closer together so it looks right; shaping is when neighbouring characters merge into one glyph — Arabic letters that join up, or an fi ligature — so their shape and width change. Both make a character’s width depend on the character beside it. Once that happens, “sum of the character widths = width of the word” no longer holds, so adding up per-character cached values gives the wrong answer.
Crimson Editor draws characters one beside the next with no such interaction, so the sum of the character widths really is the word’s width — which is exactly why a per-character cache fits.
With a more sophisticated text engine, one character’s width could depend on its neighbours, and the cache would have to be keyed on shaping results rather than single characters.
The one thing to remember
If you’re building an editor yourself, one thing from this is worth keeping.
Do work proportional to what the user can actually see right now, not to the size of the whole file.
Read and save in streaming blocks rather than one line at a time, and lay out the screen starting from the rows in front of the user. And it’s best not to repeat UI updates — like a progress bar — that a person can’t perceive changing.
Changing just these three cut Crimson Editor’s big-file open from 28 seconds to 2, and its save from 13 seconds to 0.14.