The Combinatorics of Web Typography
Ugly web typography persists because browsers sacrifice optimal layout algorithms to meet the unforgiving constraints of a 16-millisecond rendering budget.
By Leo Marchetti
Sparked by Show HN: Justif – Knuth-Plass justification and microtypography for the web · discussion

I saw a discussion on Hacker News recently about a tool called Justif. The creator introduces their project with a pretty standard premise: web typography is fundamentally broken, pointing out that despite decades of browser development, justified text still gives us jagged rivers of whitespace.
In the HN thread, the discussion immediately pivoted to a familiar complaint. Commenters were baffled that we carry supercomputers in our pockets, yet a basic article justified on a mobile screen frequently looks like a chaotic ransom note.
I get the frustration. If you compare a modern web page to a PDF generated by LaTeX in 1985, the browser looks like it's actively trying to hurt your eyes. Frontend developers are just operating under the severe, unforgiving combinatorics of a sixty-frames-per-second DOM rendering budget.
To understand what is actually going on under the hood, we can look at the baseline algorithm browsers use to draw text. Historically, web engines shove as many words onto a line as possible, calculate the leftover space, distribute it between the words, and then move down to the next line. We can model this <dfn>primitive greedy algorithm</dfn> in Python to see exactly how it behaves. But to really see why browsers are trapped, we need to compare it against the gold standard: the dynamic programming approach formalized by Donald Knuth and Michael Plass in their 1981 Knuth-Plass paper.
Instead of filling a line until it overflows, the Knuth-Plass algorithm evaluates a cost function across multiple branching paths. We define a minimum-raggedness model where the penalty for adding whitespace scales quadratically. A line with two extra spaces gets a minor penalty of four, but a line with ten extra spaces gets a catastrophic penalty of one hundred.
Here is a toy script that wraps text to thirty characters using both methods, then justifies them with spaces so we can compare the damage:
def greedy_wrap(text, width):
words = text.split()
lines, current_line, current_length = [], [], 0
for word in words:
if current_length + len(word) + len(current_line) <= width:
current_line.append(word)
current_length += len(word)
else:
lines.append(current_line)
current_line, current_length = [word], len(word)
lines.append(current_line)
return lines
def knuth_plass_wrap(text, width):
words = text.split()
n = len(words)
memo = {n: (0, [])} # stores (penalty, breaks)
for i in range(n - 1, -1, -1):
best_penalty, best_breaks = float('inf'), []
length = 0
for j in range(i, n):
length += len(words[j])
spaces = j - i
if length + spaces > width:
break
# Quadratic penalty for extra space
extra_space = width - (length + spaces)
penalty = extra_space ** 2 + memo[j + 1][0]
if penalty < best_penalty:
best_penalty = penalty
best_breaks = [j + 1] + memo[j + 1][1]
memo[i] = (best_penalty, best_breaks)
breaks, lines, start = memo[0][1], [], 0
for b in breaks:
lines.append(words[start:b])
start = b
return lines
def justify_print(lines, width):
for line in lines[:-1]:
if len(line) == 1:
print(line[0].ljust(width))
continue
spaces_to_add = width - sum(len(w) for w in line)
for i in range(spaces_to_add):
line[i % (len(line) - 1)] += " "
print("".join(line))
print(" ".join(lines[-1]))
text = "This is a demonstration of how greedy text algorithms fail. When you force words to fit the line without looking at what comes next, you get massive gaps between words that ruin readability."
print("--- GREEDY ALGORITHM ---")
justify_print(greedy_wrap(text, 30), 30)
print("\n--- KNUTH-PLASS DP ---")
justify_print(knuth_plass_wrap(text, 30), 30)
Let's feed it our standard string of text at that rigid thirty-character width and observe the output:
--- GREEDY ALGORITHM ---
This is a demonstration
of how greedy text
algorithms fail. When you
force words to fit the
line without looking at
what comes next, you get
massive gaps between words
that ruin readability.
--- KNUTH-PLASS DP ---
This is a demonstration of
how greedy text algorithms
fail. When you force words
to fit the line without
looking at what comes next,
you get massive gaps between
words that ruin readability.
The greedy output looks terrible.
We have successfully generated massive, jagged rivers of whitespace. The greedy approach makes a locally optimal choice at the end of every single line, completely blind to the disaster it might be setting up for the next one. To eliminate those rivers, the layout engine has to look ahead. By evaluating the entire paragraph at once, the Knuth-Plass algorithm knows that leaving a little extra space on line one saves us from having to stretch line two across an absurdly wide chasm.
So why don't browsers just use the dynamic programming algorithm?
Because doing this turns our neat, blisteringly fast loop into a brutal O(n²) problem.[^1] The engine must calculate the penalty for breaking after the first word, the second word, the third word, and then recursively evaluate the remainder of the paragraph for every single one of those choices.
[^1]: (Yes, I know that mathematically you can use the SMAWK algorithm to solve minimum-raggedness in O(n) time. The theoretical time complexity doesn't change the reality: the constant-factor overhead of maintaining state and evaluating the cost function for every possible break point still completely blows up a browser engine's 16-millisecond frame budget.)
A web browser has roughly sixteen milliseconds to calculate styles, perform layout, and paint the pixels to the screen. If you rotate your mobile phone, the browser has to recalculate the layout for the entire DOM tree instantly. Asking the engine to perform a dynamic programming lookahead on a sprawling blog post in real time is a total non-starter; the CPU would grind to an absolute halt.
We don't even have to guess if this constraint dictates modern design; we can check how modern specifications handle layout concessions. The official W3C CSS Text Module Level 4 spec recently introduced a highly requested feature called text-wrap: balance. This property finally tells the browser to look at a block of text and balance the line lengths, preventing those awkward dangling single words at the end of headings.
But the spec includes a massive, explicit caveat. It allows User Agents to restrict this paragraph-aware feature to a hard limit of just four to six lines. Why? Because the performance hit of evaluating whole paragraphs is far too severe to run on long body text. The specification literally permits browsers to give up and revert to our greedy first-fit algorithm if a paragraph gets too long, entirely to protect the rendering budget.
I want to reiterate that I don't write browser rendering engines for a living, so maybe modern CSS engines cheat this in ways I don't fully understand. There could be clever caching layers or SIMD-accelerated text measurements I haven't accounted for, but the math stands. The ugliness of web text persists because rendering sixty frames a second requires aggressive compromises, forcing layout engines to throw expensive paragraph lookaheads out the window. Design on the web is an exercise in navigating strict performance constraints, so please do not feel inadequate when your justified CSS refuses to look as clean as a printed book. You are simply fighting against combinatorics.