A Postgres page grows from both ends. A small array of line pointers grows from the front, row data is packed from the back, and free space sits in the middle. Every so often VACUUM compacts the page by sliding the live rows together. Why would a page need compacting if both regions just grow toward each other?
Process memory looks the same way, with the stack and heap growing toward each other, and the stack never fragments. That's because stack frames are freed in exactly the reverse order they were pushed, so the free space is always one piece at the boundary.
A page allocates like a stack: a new row always lands at the boundary. But it doesn't free like one. Deletes hit whatever row the workload picks. Write rows A, B, C, D, delete B, and there's a hole in the middle of the data region. You can't pull the boundary back past C and D to reclaim it. So the data region behaves like a heap and fragments like one: 3 KB free in total, split into a 1 KB hole, a 1.5 KB hole and 500 bytes at the boundary, still can't fit a 2 KB row.
Compaction slides C and D over to close the gap. That's why a row is addressed by its slot number rather than its byte offset. Indexes point at (page, slot), so when a row moves, only its slot entry inside the page changes, and the indexes never notice.
I'd assumed two-ended growth meant the page was hole-free like a stack. I had missed that a stack stays hole-free only because it frees in last-in, first-out order.
Placing variable-size blocks when frees come in arbitrary order is the subject of Wilson, Johnstone, Neely and Boles, "Dynamic Storage Allocation: A Survey and Critical Review" (1995).