Look at the last line of almost any paragraph on the web. Quite often it's one short word, alone.
That isn't bad luck with the copy. It's the algorithm. Drag the width below and compare three ways of breaking the same paragraph.
Greedy: one line at a time
The browser's default is the simplest method there is. Put words on the line until the next one won't fit. Then start a new line, and never look back.
function greedy(W) {
const breaks = [];
let start = 0;
for (let k = 1; k <= words.length; k++) {
if (k < words.length && lineWidth(start, k + 1) > W) {
breaks.push(k);
start = k;
}
}
breaks.push(words.length);
return breaks;
}
WebKit calls this "first-fit" or greedy. It's one pass, each line is decided once, and the cost grows in a straight line with the length of the text. That matters, because the browser re-runs it every time the width changes, a font loads, or you type a letter.
The weakness is that each decision is local. Greedy fills line two as full as possible without knowing that this leaves an awkward word for line three. It can't trade a slightly worse line now for a much better one later.
You see that as a ragged right edge, where long and short lines alternate, and as a short last line, where a single word gets left over at the end.
What a better answer looks like
To do better, you need a way to score a whole paragraph. The simplest useful score is to take the slack on each line, square it, and add them up. The last line doesn't count, because it's allowed to be short.
Squaring is the important part. Two lines that are each 10px short cost 200. One line that's 20px short and one that's full cost 400. So the score prefers evenly spread gaps over one big hole, which is what your eye prefers too.
Now the question is which set of breaks gives the lowest total. Trying every combination is hopeless. A paragraph with 60 places to break has up to 2⁶⁰ ways to do it.
Knuth and Plass: the whole paragraph at once
In 1981, Donald Knuth and Michael Plass published Breaking Paragraphs into Lines, the algorithm behind TeX. The key insight is that the best way to set everything after a given break doesn't depend on how you got to that break.
So you can work backwards. For the last word, the cost of the rest is zero. For each earlier break, try every line that could start there and still fit, and add that line's cost to the best cost of whatever comes after it. Keep the cheapest. That's dynamic programming, and it's what the third version in the demo runs:
cost[n] = 0;
for (let i = n - 1; i >= 0; i--) {
for (let j = i + 1; j <= n; j++) {
const w = lineWidth(i, j);
if (w > W) break;
const line = j === n ? 0 : (W - w) ** 2;
if (line + cost[j] < cost[i]) {
cost[i] = line + cost[j];
next[i] = j;
}
}
}
Each break only looks ahead as far as one line can reach, so in practice this runs in close to linear time.
Notice the third version can still end on one word. The last line is free, so nothing in this score cares how short it is. That needs its own penalty, and it's the first thing text-wrap: pretty goes after.
TeX's real scoring is richer than squared slack. Its badness grows with the cube of how far the spaces had to stretch or shrink, and each line's demerits square that again, plus extra costs for hyphens and for a tight line next to a loose one. But the shape of the idea is the same: price every line, and find the cheapest path through the paragraph.
Why browsers don't do this everywhere
Total fit is slower, and the bigger problem is that it isn't stable. Change one word near the end, and the best breaks near the start can change too. In a text field, that means lines above your cursor jumping around as you type.
TeX doesn't care, because it typesets a finished document once. A browser lays out live text, at every width, all the time. So greedy is the default, and the better methods are opt-in. CSS even has a value for the editing case, text-wrap: stable, which keeps the lines before an edit where they are.
What CSS gives you
There are two opt-ins, and they solve different problems.
text-wrap: balance makes every line about the same length. It's for headings and captions. According to Chrome's docs, it finds the narrowest width that doesn't add a line, by binary search to the nearest pixel. That's the balanced version in the first demo. It's expensive enough that browsers cap it: MDN gives six lines or fewer for Chromium and ten for Firefox. Past that, you get normal wrapping.
text-wrap: pretty is for paragraphs. It tells the browser to spend more time for a better result, and each engine decides what that means. Chrome's version, since Chrome 117, only adjusts the last four lines, mostly to avoid a single word on the last line. Safari 26 goes further: it looks at every line in the paragraph, evening out the ragged edge, reducing hyphenation and preventing short last lines. That's much closer to Knuth and Plass.
WebKit's advice is that the cost depends on paragraph length, not on how many elements use it. You'd need paragraphs hundreds or thousands of lines long to notice. I'd put pretty on body text and balance on headings, and leave anything people edit on the default.
Where a line can break at all
Everything above assumes you know where breaks are allowed. In English it's mostly spaces, but the real rules are in Unicode's line breaking algorithm, UAX #14, and they're about much more than spaces.
Chinese and Japanese text has no spaces, and a line can break between almost any two characters, with exceptions like never starting a line with closing punctuation. Thai, Lao and Khmer don't use spaces between words either, but breaking anywhere would split words, so browsers use dictionaries to find the word boundaries.
Hyphenation adds more break points inside words. hyphens: auto turns it on, but it needs a language: browsers only hyphenate when the element has a lang attribute and they have a dictionary for that language. Without lang="en" on your page, hyphens: auto silently does nothing.
A line breaker is only as good as the break points it's given. Get those right first, then decide how much time you want the browser to spend choosing between them.