Two-Column Waterfall Layouts and Image Size Governance: Uprooting 'Load-Then-Shift', with Xiaohongshu as the Example
Ninety percent of a waterfall layout's UX problems come from 'unknown image height'. Starting from Xiaohongshu's feed scenario, this article covers the complete solution: the placeholder height formula, greedy column assignment, governance at upload time, and lazy loading.
The Problem Scene: Three Kinds of "Jump"
Anyone who has used Xiaohongshu may have noticed these details—a good feed "doesn't jump." Specifically, a rough implementation jumps in three ways:
- Load-jump: before the card's image finishes downloading, its height is 0; as images arrive one by one, content below gets pushed down again and again, and the card you were about to tap runs away.
- Length-jump: with naive left-right alternation, a run of tall images can all land in the same column; the two columns differ in length by a full screen, and the visual center of gravity tilts.
- Scroll-jump: after scrolling deep into the feed, frames drop and interactions stutter; the node count grows linearly with scroll position, until the whole page drags like it's stuck in mud.
All three jumps share the same root cause: the card's height is unknown until the image finishes loading. And with height unknown, placeholders, column assignment, and recycling are all impossible. So "image size governance" isn't a detail optimization—it's the foundation of a waterfall layout.
Root Cause: At Which Step Is the Height Information Lost?
When is an image's height known?
- At upload time: once the user picks the image, the file carries its width and height—known
- When the server receives it: parse the file header and you have the width and height—known
- When the list API returns it: as long as the server stored it, it can be returned together—known
- When the browser finishes downloading: only known after
img.onload—this is where the information is first "obtained," even though it could have been had for free
The problem: the information exists in the first three stages but gets discarded; the frontend insists on waiting until the last stage (download complete) to "rediscover" it, then is forced to absorb that discovery through layout jank.
The governance idea can be summed up in one sentence: store the information at the earliest step where it's known. Parse the width and height at upload time and store them in the database; the list API returns them with the note; every piece of data the frontend receives naturally carries width and height.
The Core Formula: Placeholder Height = Column Width × Height ÷ Width
With the width and height returned by the server, the frontend can compute the display height before the image downloads:
// two-column layout: column width = (container width - column gap) / 2
const COLUMN_GAP = 8;
const columnWidth = (containerWidth - COLUMN_GAP) / 2;
// placeholder height: stretch proportionally by aspect ratio, so dimensions are identical before and after load
function placeholderHeight(width: number, height: number): number {
return Math.round((columnWidth * height) / width);
}When rendering, use this height to reserve the placeholder block (a solid color, ideally close to the image's dominant color); when the image finishes loading, swap it in place—zero shift. The card the user taps is exactly the card they saw from start to finish.
Two refinements on the placeholder block:
- A solid color, not a spinner. A loading spinner makes the page "feel" slower; a quiet color block lets the layout settle first.
- Keep the placeholder on failure. If the image fails, show a unified failure icon in the placeholder area: the card doesn't collapse, content below doesn't jump—this is the dividing line between "governed" and "flying naked."
Column Assignment: New Cards Go into the "Shorter Column"
Left-right alternation (1, 3, 5 left; 2, 4, 6 right) is the most intuitive implementation, and also the easiest to unbalance: a "tall, short, tall, short" sequence stays balanced, but "tall, tall, tall" piles everything into one column.
The correct approach maintains a running height for each column and sends new cards into the shorter one:
class Waterfall {
private left: HTMLElement[] = [];
private right: HTMLElement[] = [];
private leftHeight = 0;
private rightHeight = 0;
add(card: HTMLElement, height: number) {
if (this.leftHeight <= this.rightHeight) {
this.left.push(card);
this.leftHeight += height + GAP;
} else {
this.right.push(card);
this.rightHeight += height + GAP;
}
}
}One height comparison, O(1) cost, and the two columns stay close in length. The precondition for this algorithm to work is exactly the formula from the previous section—card heights must be computable in advance, or there's no way to compare "the shorter column."
Governance at Upload Time: Kill the Problem at the Moment of Upload
The main battleground for size governance is actually outside the client. Taking Xiaohongshu's publishing pipeline as an example, the server must do four things:
- Parse width/height into the database: read the file header when receiving images, get the true dimensions, and store them with the note's metadata. The list API returns them as-is.
- Validity checks: images with zero width/height or an aspect ratio beyond a threshold (like 1:10 "hot dogs") are either rejected or cropped server-side. Extreme ratios aren't bad data, but they can destroy the layout.
- Generate thumbnails: at upload time, generate thumbnails at the target spec (2× the list column width, for high-DPI screens) and keep the originals for the detail page. Lists always use thumbnails—a single original can be two or three megabytes, and ten on the first screen means twenty or thirty megabytes of traffic.
- Backfill historical data: what about old notes from before the width/height fields shipped? Write a script to backfill them. The acceptance criterion is simple: after backfilling, old content stops jumping.
Item 3 deserves expansion: thumbnails must be generated once at upload time, not resized on the fly at request time. Real-time resizing pays the compute cost repeatedly per request and slows the first screen; generating at upload is "pay once, use forever."
Lazy Loading and Node Recycling: Making Long Lists Scrollable
The final dividend of size governance is "recyclability." A feed rendered in full with v-for grows its node count linearly with data—scroll to a few hundred items and you have thousands of DOM nodes; not dropping frames would be a miracle. But if every card's height is precisely known, you can:
- Lazy loading: only request images near the viewport (plus one screen of buffer), avoiding dozens of competing requests the moment the home page opens. Note that preloading isn't free—it steals bandwidth you need right now.
- Node recycling: keep only DOM nodes within ±N screens of the viewport; remove nodes that scroll far away and rebuild them from data as they scroll back. Precisely computable heights are the precondition for recycling to work: otherwise, the moment you remove a node, the scroll height stops matching up.
When using skeleton screens, the skeleton must match the real structure—for a two-column waterfall, draw the outline of two-column cards, then swap smoothly when data arrives, avoiding a second jump of "skeleton in the middle, content on the sides."
Common Objections and Responses
"Isn't CSS columns simpler?"
The balancing of columns is done automatically by the browser following document flow; you can't specify "this one goes into the shorter column," and it doesn't work with node recycling. It can produce the shape of a waterfall, but not a controllable waterfall.
"What about an equal-height grid, row by row—heights are naturally fixed. Isn't that nice?" It is nice, but it's not a waterfall—tall images get cropped down to a sliver, and short images get surrounded by whitespace. In Xiaohongshu's feed, the image is the content; cropping the image is cropping the content.
"The frontend can compute it from the height once it has it—why must the server provide it?" The frontend can compute it, but only after downloading. The server provides "compute before download," which is the difference between zero shift and shift-after-load. Besides, originals start at two megabytes; downloading the whole original just to compute a height puts the cart before the horse.
"Old data has no width/height—can the frontend just fall back for now?" You can fall back temporarily (fixed-ratio placeholders plus correction after load), but the moment of correction still jumps. A fallback buys time; backfilling is the cure.
Summary
String the whole pipeline together, and the layers of governance are actually quite clear:
| Stage | Action | Problem solved |
|---|---|---|
| At upload | Parse width/height into the DB + validity checks + thumbnail generation | Height information isn't lost, extreme ratios are defended against, bandwidth is saved |
| List API | Return width/height and thumbnail URLs | The frontend can compute the layout before download |
| Frontend rendering | Placeholder formula + greedy column assignment + solid-color placeholders | Zero shift on load, balanced columns |
| Frontend scrolling | Lazy loading + node recycling + consistent skeletons | Long lists don't stutter, node count is bounded |
A waterfall is a component that "looks like it's all CSS but actually depends entirely on data." The essence of image size governance is moving "things you only know at download time" to "decided at upload time"—the ceiling of frontend optimization often lies further up the pipeline.