I dug into Pretext codebase. Lessons we can learn.
General programming lessons I extracted while swimming through the pretext codebase.

Just a guy who loves to write code and watch anime.
1. Separate what you know once from what changes
There is work that depends on data that rarely changes, and work that depends on data that changes constantly. When you mix them together, you pay the expensive cost every time the cheap thing changes.
In Pretext, the text and font do not change when someone resizes the window. Only the container width changes. So prepare does all the expensive work once, breaks the text into pieces, measures every piece, caches the widths. Then layout just walks an array of numbers and counts how many fit per line. Pure arithmetic. 0.0002ms.
prepare(text, font) → bag of numbers // expensive, once
layout(bag, width, lineHeight) → height // cheap, every resize
The question to ask yourself is, what work am I redoing that I already have the answer to.
2. Canvas measureText as a cheap side door
You need to know how wide some text is. The standard way is to put it in a DOM element, call getBoundingClientRect. But the moment you read that, the browser recalculates the position of everything on the page. This is called layout reflow, it is one of the most expensive things a browser does. Do it 500 times in a loop, your page freezes.
Canvas has measureText on its 2D context. You set the font, call measureText("hello"), and it returns the exact pixel width using the browser's font engine. No reflow triggered. No DOM involved. You do not even need to attach the canvas to the page.
const ctx = new OffscreenCanvas(1, 1).getContext("2d");
ctx.font = "16px Inter";
const width = ctx.measureText("hello world").width; // exact pixel width, zero cost
When you need information from the browser, look for the cheapest API that gives it to you. Sometimes there is a path that skips the expensive work entirely.
3. Binary search over a cheap function
You have a chat bubble with max-width. The text wraps inside. But the bubble is wider than it needs to be, the longest line does not reach max-width. You want the tightest width that still keeps the same number of lines.
Here is the key observation. As you shrink the width, the line count can only stay the same or go up. It never goes down. Narrower container means same or more lines, always. When a value can only go one direction like this, you can binary search it.
function findTightestWidth(prepared, maxWidth, lineHeight) {
// how many lines does the text take at max width
const targetLines = layout(prepared, maxWidth, lineHeight).lineCount;
let lo = 1;
let hi = maxWidth;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
// does this narrower width still fit in the same number of lines
if (layout(prepared, mid, lineHeight).lineCount <= targetLines) {
hi = mid; // yes, try even narrower
} else {
lo = mid + 1; // no, too narrow, line count went up
}
}
return lo; // tightest width that still fits
}
Around 10 iterations. Each costs 0.0002ms. You now have pixel-perfect tight-fitting bubbles.
Binary search is not just for sorted arrays. It works anytime "bigger input means bigger or equal output" holds. Or the reverse. As long as the relationship only goes one direction, you can binary search it.
4. Parallel arrays instead of arrays of objects
The obvious way to store a list of things with properties.
const segments = [
{ text: "hello", width: 42, kind: "text" },
{ text: " ", width: 4, kind: "space" },
{ text: "world", width: 37, kind: "text" },
];
Pretext uses parallel arrays instead.
const texts = ["hello", " ", "world"];
const widths = [42, 4, 37];
const kinds = ["text", "space", "text"];
Same data. Why it matters.
Memory layout. Your CPU does not read one number at a time from memory. It reads a chunk of nearby bytes all at once into a fast local cache. With an array of objects, each object lives in its own spot in memory. The width of object 1 and the width of object 2 might be far apart. With a parallel array, all 500 widths sit right next to each other. One read loads a bunch of them at once. This adds up when you loop over lots of data.
You skip what you do not need. The code that counts lines, which runs the most, never reads texts. That array is only touched when you ask for the actual line strings. With objects, every field is always there even when you ignore most of them.
Fewer allocations. One array of 500 numbers is one allocation. 500 objects with a width field is 500 allocations. Less work for garbage collection. Less work for the garbage collector.
This is the core idea behind Entity Component Systems in game engines. Instead of each entity being an object with health, position, velocity, you have a health array, a position array, a velocity array. The physics system only touches position and velocity. The rendering system only touches position and sprite.
Particle systems. Thousands of particles per frame. Parallel arrays of x, y, vx, vy, life. The update loop runs fast.
Data tables. Columnar storage is the same idea. ClickHouse and DuckDB use it because scanning one column is faster than scanning all columns per row.
Animation systems. Arrays of start times, durations, easing functions, current values. The tick loop touches only what it needs.
As a guideline, if you have more than a few hundred items and a loop that runs many times reading only some fields, parallel arrays will be faster.
5. Collect platform quirks into a profile object
Every browser renders text slightly differently. Safari rounds line widths to 1/64th of a pixel. Chrome carries CJK characters after closing quotes differently.
The obvious approach is to scatter if (isSafari) checks everywhere. You cannot tell which behaviors differ across browsers without reading every conditional in every file.
Pretext puts all platform differences in one typed object, computed once at startup.
const engineProfile = {
lineFitEpsilon: isSafari ? 1 / 64 : 0.005,
carryCJKAfterClosingQuote: isChromium,
preferPrefixWidthsForBreakableRuns: isSafari,
preferEarlySoftHyphenBreak: isSafari,
};
The rest of the code reads engineProfile.lineFitEpsilon. It does not know which browser it is on. The algorithm is clean. The quirks are in one place.
Testing becomes easy too. Inject a fake profile and the algorithm behaves the same way every time.
Cross-platform apps. iOS vs Android, Electron vs browser. One profile per platform, clean algorithm code.
Feature flags. Instead of
if (featureFlags.newCheckout)in 40 places, build a config at startup and pass it through.Game difficulty. Easy, medium, hard as profile objects. Game logic reads
profile.enemySpeed, notif (difficulty === 'hard').
Detect once at the edges, encode as data, pass the data through.
6. Measure the error once. Correct for it forever.
Chrome and Firefox canvas measure emoji wider than the DOM actually renders them at small font sizes. About 2 pixels per emoji character. If you trust the canvas number, your line breaks are wrong.
Pretext measures one emoji two ways, canvas and DOM, computes the difference, and uses that as a constant correction for all future emoji measurements. One DOM read per font, then pure math forever.
function getEmojiCorrection(font) {
ctx.font = font;
const canvasWidth = ctx.measureText("😀").width;
span.style.font = font;
span.textContent = "😀";
document.body.appendChild(span);
const domWidth = span.getBoundingClientRect().width;
document.body.removeChild(span);
return canvasWidth - domWidth; // constant correction per emoji
}
This works because the error is systematic, not random. Same offset for every emoji at a given font size. One measurement reveals the pattern.
When two systems that should agree do not, measure the disagreement once and correct for it.
7. Answer questions about results without building the results
Pretext has two ways to get line information. layoutWithLines builds the actual text string for each line. walkLineRanges gives you widths and cursor positions only, no strings created.
// builds the full text string for every line
const { lines } = layoutWithLines(prepared, 320, 26);
// lines[0].text === "hello world this is"
// just the numbers, no strings
let maxWidth = 0;
walkLineRanges(prepared, 320, (line) => {
if (line.width > maxWidth) maxWidth = line.width;
});
// maxWidth === 287.3
If all you need is the widest line, building every line's text is wasted work. You paid for string construction you threw away.
Separate "what are the properties of the result" from "give me the actual result." Often you only need the properties.
Build a cheap path that answers questions about output without producing the output. Offer both paths. Let the caller choose.
8. Check if already solved
Splitting text on spaces works for English. Thai has no spaces between words. Chinese and Japanese can break after every character but not before certain punctuation. Arabic letters change shape depending on context. A family emoji is actually multiple characters joined by invisible characters that connect them together.
The browser already knows how to handle all of this. Intl.Segmenter exposes that knowledge.
const segmenter = new Intl.Segmenter("th", { granularity: "word" });
const segments = [...segmenter.segment("สวัสดีครับ")];
// correctly segments Thai into words without spaces
Pretext builds its entire analysis pipeline on top of this. Instead of reimplementing Unicode word boundary rules, it lets the browser handle that and applies its own merging and splitting on top.
Search tokenization. Segment input into words for a search index. Works across all languages without language-specific tokenizers.
Text highlighting. Find word boundaries for double-click selection or search result highlighting. Works for CJK, Thai, Arabic.
Grapheme-safe truncation.
granularity: 'grapheme'lets you truncate text without cutting emoji or combined characters in half.Word counting. Count actual words in multilingual text, not just space-separated tokens.
Before building something from scratch, check if the platform already solved it. Intl.Segmenter, Intl.Collator, Intl.NumberFormat. These APIs encode decades of internationalization work.






