Back to Explore
GuideVibeFix 编辑部Updated Nov 6, 2026

Hold the Space Before the Picture Arrives: Lazy Loading and Placeholders for Vibe Projects

Images are the heaviest resource on a page and the easiest to lose control of: native lazy loading covers ninety percent of cases with one attribute, IntersectionObserver takes over 200 pixels before the viewport, aspect-ratio placeholders hold the space, blurred previews and dominant colours ease the wait, first-screen images go first, failures get a fallback — plus a seven-item pre-launch checklist.

Real photograph of hands holding a phone browsing a photo grid, with a cup of coffee on the table

The Pictures Had Not Loaded Yet, and the Page Already Jumped: A Fictional Lesson First

Here is a fictional teaching example. A-Li built a travel photo app called "Wander and See" on her own with AI. The home page is a photo wall that can show more than a hundred travel photos at once. On the first day after launch, she opened the home page on the subway: the page started blank, then pictures popped out one by one, and every time one appeared, the text below jumped down a notch. She was about to tap a photo of Dali when the picture had not finished loading and its position suddenly shifted half a screen down — her finger landed on a photo of Lijiang instead. Three identical messages arrived in the feedback group: "Why does your home page keep jumping around?"

When A-Li investigated, the problem sat at the most basic level: the home page loaded all hundred-plus original images at once, burning tens of megabytes of data before she had even finished the first screen. Worse, none of the images had reserved space — before an image arrived, the browser had no idea how tall it was, so it laid the page out at zero height and pushed everything down once the image arrived. Images arrived one by one, and the page jumped one by one. Users were not looking at a photo wall; they were playing whack-a-mole.

This is the most common way image loading dies: treating images as decoration that is "done once placed", instead of a loading process that needs a queue, a placeholder, and a fallback. Images are the heaviest resource on a page and the easiest to lose control of: one uncompressed original can outweigh hundreds of lines of code, one failed image leaves an ugly broken-image icon, and one image without reserved space can shove the whole page up and down. What users want from images is simple: see the space immediately when the page opens, load each image as they scroll to it, and get a decent explanation when loading fails.

This guide covers how to do image lazy loading and placeholders well for a vibe project: how far the browser's native lazy loading goes, when you need to take over yourself, how to build placeholders that stop layout jumps, how to prioritise above-the-fold images over the rest, what to do when loading fails, and a pre-launch checklist. All examples below are fictional teaching examples; the app, people, and numbers are invented for illustration. Specific numbers in this guide (such as triggering 200 pixels early, or a 20-pixel-wide blur placeholder) are engineering recommendations from this guide, not cited data.

Real photograph of hands holding a phone browsing a photo grid, with a cup of coffee on the table

1. Draw the Boundary First: Lazy Loading Decides "When to Load", Placeholders Decide "What It Looks Like Before"

Before writing code, separate two things. Lazy loading answers "when do we fetch this image": images on the first screen load at once, images further down wait until the user is about to scroll to them. Placeholders answer "what does this spot look like before the image arrives": how tall it is, what colour it is, whether a tiny blurred preview stands in for it. The two have different goals: lazy loading saves bandwidth and first-screen time, placeholders protect layout stability. Lazy loading without placeholders still jumps; placeholders without lazy loading are still slow on the first screen.

Another common confusion: lazy loading is not compression. Turning a 5 MB original into a lazily loaded image makes the first screen faster, but when the user scrolls to that image they still have to download 5 MB — mobile data still burns and loading is still slow. The correct order is compress first, then lazy-load: crop images to the size actually displayed, convert them to a smaller format, never serve a 4,000-pixel original where 800 pixels will do; only then decide which images load at once and which wait for scrolling. Reverse the order and lazy loading only hides the problem instead of solving it.

Scope note against existing guides on this site: this guide covers only frontend loading strategy — when to load, how to build placeholders, and what to do on failure. It does not cover the upload, cropping, and storage pipeline — that belongs to the image processing guide; nor the file previewer's interaction design; nor CDN cache configuration. Clear division of labour: one guide, one problem.

2. Step One: Start With the Browser's Native Lazy Loading — Enough for Ninety Percent of Cases

Many vibe projects reach for IntersectionObserver immediately, but modern browsers already build the most common lazy loading into a native attribute: add loading="lazy" to an img tag and the browser waits until the image is about to enter the viewport before downloading it. One attribute, no JavaScript at all, and it is enough for ninety percent of list pages, article pages, and photo walls.

<!-- First-screen image loads at once; the rest load lazily -->
<img src="dali.jpg" alt="Sunset over Erhai Lake in Dali" loading="eager" fetchpriority="high" width="1200" height="800" />
<img src="lijiang.jpg" alt="Early morning in Lijiang old town" loading="lazy" width="1200" height="800" />
<img src="shangri-la.jpg" alt="Snow mountains in Shangri-La" loading="lazy" width="1200" height="800" />

Three easily missed judgements hide in this snippet. First, do not lazy-load the first-screen image: loading="eager" plus fetchpriority="high" tells the browser to download the image users see first, first. Putting lazy on the hero image is the most common backwards move — users open the page, see an empty slot, and wait an extra round trip before the browser even starts downloading. Second, always write width and height: the browser uses those two numbers to work out the image's height in advance, and without them placeholders have nothing to stand on — the next section goes into detail. Third, never leave alt empty or write just "image": describe what the picture shows, because when the image fails to load, or when a screen reader user meets it, that alt text is all there is.

Native lazy loading has two limits worth knowing. First, it only applies to img and iframe — not to background images set in CSS. Background images that need lazy loading require your own IntersectionObserver, or simply swapping them for real img tags. Second, every browser decides for itself how early "about to enter the viewport" is, and you cannot control that distance. If there is a visible flicker between placeholder and sharp image, or images cannot keep up when users scroll fast, the scenario has outgrown the native solution and it is time to take over yourself — which is the next section.

3. Going Further: Take Over With IntersectionObserver and Start Loading 200 Pixels Early

When you need finer control — starting the download 200 pixels before the image enters the viewport, swapping in a fallback when loading fails, fading in when loading succeeds — take over with IntersectionObserver. The idea fits in one sentence: tell the browser "watch these images for me and notify me when one is about to enter the viewport", then fill in the real image URL when the notification arrives.

The early-trigger distance is the most important number here. This guide recommends rootMargin: "200px": start downloading when the image is still 200 pixels away from the viewport. At normal scrolling speeds the image is mostly downloaded before it enters the viewport, so there is no flicker from placeholder to sharp image — while 200 pixels is not so early that a dozen images below the screen all get pulled down at once. For very fast scrolling (such as dragging the scrollbar straight to the bottom), 200 pixels may not be enough and you can widen it to 400; for data-sensitive scenarios you can tighten it to 100. Start at 200, then adjust from real measurements.

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return;
    const img = entry.target;
    img.src = img.dataset.src; // real URL lives in data-src
    img.onload = () => img.classList.add('loaded'); // fade in on success
    img.onerror = () => img.classList.add('failed'); // fallback styling on failure
    observer.unobserve(img); // stop watching once loaded
  });
}, { rootMargin: '200px' });

document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));

Three details in this code are worth copying. First, the real URL lives in data-src and src starts out holding only the placeholder or nothing at all — that is the precondition for lazy loading to work at all. If the address is already in src, the browser downloads it as soon as it parses the tag and the takeover is pointless. Second, unobserve as soon as loading finishes: watching an image that has already loaded wastes the browser's observation slots, and the longer the list the more it wastes. Third, success and failure both need an explicit state class: loaded for the fade-in, failed for the fallback styling — neither state may be "do nothing".

Keep the fade-in restrained: 200 to 300 ms from transparent to opaque is enough. Longer and users feel the picture "takes forever to appear"; shorter and the transition is invisible. And respect prefers-reduced-motion: when users have turned on "reduce motion" in their system settings, switch the fade-in off entirely and show the image directly — never make people dizzy for the sake of looking nice.

Diagram: the journey of lazy loading — placeholder, enter viewport, load, success or fallback

4. Placeholders: Hold the Space First, and the Page Stops Jumping

This is the most important section in the whole guide, because layout jumping is the flaw users feel most strongly — and the easiest to measure. Before an image arrives, the browser does not know how tall it is, so it lays the page out at zero height; once the image arrives, the page is pushed down a notch. There is only one fix: tell the browser in advance how wide and tall the image is, so the space is waiting there whether the image has arrived or not.

The most reliable approach is aspect-ratio plus the width-to-height ratio. If an image is 3 by 2, write aspect-ratio: 3 / 2 on its container; combined with width: 100% and height: auto, the browser can compute the exact height before the image arrives. The cost is almost zero and the effect is the most certain — as long as every image's ratio is correct, layout jumping is eliminated at the root. Where do the ratios come from? Store the width and height when the image is uploaded, and render them directly on the frontend; for older images without stored dimensions, give each category a default ratio — 4 by 3 for the photo wall, 1 by 1 for avatars — so nothing jumps first, then fill in real ratios over time.

Holding an empty space is still not good enough — staring at blankness while waiting leaves users with no sense of what is coming. Two advanced approaches help. The first is the blurred tiny image: load a miniature version only 20 pixels wide first — one or two kilobytes, back almost instantly — stretch it out and add a blur, and it becomes a "silhouette" of the picture; fade in the original once it arrives. Users see a picture going from blurry to sharp instead of blankness, and the sense of waiting drops sharply. The second is filling the placeholder with the image's dominant colour: take the main colour at upload time, store it as a colour value, and use it to fill the placeholder area on the frontend. A solid-colour background looks better than blankness and is simpler than a blurred preview — pick one of the two, never stack them, because a colour block on top of a blurred thumbnail is pure redundancy.

Skeleton screens are a third approach, but use them with care. Skeletons suit whole regions that are loading — an article body, a card list — not a single image. Wrapping every image in a flickering skeleton means dozens of grey blocks flashing together across the page, and users feel the entire page is "under construction" — worse than blankness. The rule: skeletons belong on "a region", never on "one image".

5. Priority: First-Screen Images Go First, Decorative Images Wait

Images need a pecking order too. The hero image on the first screen is what users see first, so it gets top priority: load immediately, highest priority, even preloaded. List images load in scroll order — whoever enters the viewport first goes first. Decorative background images, small avatars in a wall of faces, and pictures near the bottom of the page get the lowest priority and wait until the important images are mostly done.

Three concrete practices follow. First, give the hero image fetchpriority="high" and never lazy-load it; a page should have at most one or two high-priority images, because marking everything high means nothing is high. Second, reserve preloading for the one hero image you are certain about: add link rel="preload" as="image" in the head and the browser starts downloading before it even reaches the img tag. Preloading used wrongly is worse than not preloading at all — preloading an image users will never see steals bandwidth from the hero image. Third, serve srcset by display size: an image displayed 400 pixels wide on a phone should not be served the 1,200-pixel version; the browser picks the best fit from the widths and pixel densities you provide. A factor of two between sizes is enough — 400, 800, and 1,600 cover almost every case. Sizes sliced too finely cost more in maintenance than they save in bandwidth.

One more attribute worth adding while you are there: decoding="async". It tells the browser to decode images off the main thread, so pictures decode in the background while the page scrolls and appear once ready. With it, stutters caused by image decoding during scrolling drop sharply; without it, a single large image can make the page hitch for the instant it decodes. The attribute carries no compatibility burden — browsers that do not support it simply ignore it — so there is no downside to adding it.

6. Failure Fallbacks: When an Image Breaks, Still Leave a Decent Face

Image loading failure is not a rare event: the original was deleted, a CDN node hiccuped, the user's signal dropped in the subway — any of these can kill an image. Without a fallback, a failed image leaves a broken-image icon plus a string of alt text. It looks bad, and users have no idea what the picture was supposed to be. Build fallbacks on two levels.

The first level is per-image: when onerror fires, do not retry repeatedly — an address that failed will very likely fail three times in a row, burning data for nothing. The right move is swapping in a default image, or replacing the whole image area with a placeholder block carrying an icon and the words "Image failed to load — tap to retry". "Tap to retry" gives users the choice; it is not automatic retry. Retrying automatically three times and failing, then asking the user to tap once more, makes users feel the app cannot even control its own failures.

The second level is page-wide: if more than half the images in a list fail, the problem is probably not any single image but the image service itself. Do not make users tap retry one by one — put a banner at the top of the list: "The image service seems to have a problem and is recovering — come back in a bit." One sentence tells users it is not their network and not their mistake. Once the banner appears, the backend must alert immediately — an image service outage is more urgent than any feature bug, because it is the first thing users see.

7. Mobile and Data: Saving Data Is Saving Experience

On mobile, lazy loading images is about saving data, and saving data is saving experience. Users scrolling your photo wall on the subway pay per megabyte — every unnecessary original downloaded is money burned. Beyond serving images by size, three mobile-specific practices apply. First, when users have turned on data-saver mode (Save-Data), load low-resolution versions by default and only fetch the original when they tap the image — the browser offers this signal ready-made, so there is no need to guess. Second, in weak-network conditions, hold off loading below-the-fold images until the connection recovers — navigator.onLine and the online event can tell you that. Do not leave users staring at spinners with no signal. Third, prefer modern, smaller image formats and fall back to older formats where the browser does not support them; the savings are real — half the size per image means half the data across a hundred images.

One easily missed point: images users upload need managing too. A user picks a 12 MB original straight from the phone gallery and uploads it, the server stores it as-is, and the frontend displays it as-is — no lazy loading can save that. The upload pipeline must include compression and cropping: crop to the largest display size needed, convert to a suitable format, generate several size variants, then store. Frontend lazy loading is the last line of defence; processing at upload time is the first.

8. Carousels and Endless Lists: The Two Scenarios Most Likely to Break

The general lazy-loading practices are covered, but two scenarios are especially prone to breaking and deserve their own section. The first is the home-page carousel. Carousel images look like first-screen images, but only the first one is what users see at first glance — the second and third hide behind the carousel and users may never swipe to them. Two common mistakes appear here: treating every carousel image as a first-screen image and loading them all at once, which adds three or four large images' worth of data to the first screen; or lazy-loading all of them, so users who swipe to the second image meet a blank slide while it starts downloading. The right approach splits them up: the first image is a first-screen image — load immediately, top priority; the second starts downloading once the first has finished, or when the user first interacts with the carousel; the third and later preload when the carousel reaches the previous one. Autoplay needs care too: never auto-advance or preload later slides while the page sits in a background tab — every byte burned before the user switches back is wasted.

The second scenario is the infinitely scrolling long list. In a photo wall or product feed that scrolls forever, lazy loading accumulates more and more observed targets: dozens at first, then a hundred, then a thousand as users keep scrolling — and the browser's burden grows with them. Two fixes go together. First, unobserve as soon as loading finishes — already covered above, and worth repeating here: in a long list, failing to stop observing makes the page slower the further users scroll. Second, the list itself needs virtual scrolling or paged recycling: image nodes far outside the viewport should not stay in the document forever. Recycle an img node once it has scrolled far away, keep only its placeholder, and reattach it when the user scrolls back. Virtual scrolling sounds heavy, but for a list of a thousand images it is the only way to stop the page getting slower as users scroll; if your list holds only a few dozen images, lazy loading plus prompt unobserving is enough — do not bring in virtual scrolling "just in case".

Both scenarios share one more requirement: test on a real phone, not just by dragging the scrollbar in a desktop browser. A carousel that flips instantly on desktop can stutter on a phone because the images are too large and slow to decode; a list that scrolls a thousand images smoothly on desktop can start dropping frames after two hundred on a budget Android phone. Test with a mid-range or low-end phone on a slow connection, scroll from top to bottom, and count the jumps and stalls — that tells you more than ten metrics in the performance panel.

One last point about the data bill, the easiest to overlook: the number of image requests is itself a cost. Many list pages prefetch the next dozen images before users scroll, assuming they will get there eventually. But the data does not lie: on an ordinary photo-wall page, users on average leave after the first two screens, so images prefetched for the third screen and beyond are mostly downloaded for nothing. Prefetch with restraint: only the next screen, never three screens ahead; and when users suddenly scroll fast, stop prefetching at once and make sure the images in the current viewport come out first. Only when the data bill is properly counted has lazy loading genuinely saved money — otherwise it just trades "slow first screen" for "downloading useless images the whole way down".

9. The Pre-Launch Checklist

Run this list item by item before image lazy loading goes live. First, throttle the network to slow 4G, open the home page, and confirm the hero image appears within two seconds and the page never visibly jumps at any point. Second, scroll slowly to the bottom and count how many image requests fired beyond the first screen — images never scrolled to must fire zero requests. Third, drag the scrollbar quickly to the bottom and confirm images fill in order, rather than a pile of placeholders flickering at once. Fourth, go offline and refresh — every failed image must show its fallback styling, with no broken-image icon anywhere. Fifth, open the developer tools performance panel and confirm the layout-shift metric is within a reasonable range — looking stable to the eye is only a pass mark; the metric must pass too. Sixth, open the page on a phone in data-saver mode and confirm only low-resolution versions load by default. Seventh, pick three images at random and check their alt text describes what the picture shows, not just the word "image".

One last word on how to judge the result. The quality of image loading is not measured by how advanced the technology sounds, but by three numbers: how many seconds until the first-screen image appears, how many times the page jumped while scrolling, and whether every failed image has a fallback. The first should be as small as possible, the second ideally zero, and the third must be one hundred percent. When all three pass, your image loading genuinely saves users time and data; pile on technical terms while the page jumps around on opening, and users will only conclude the app is unreliable. The goal of image loading is not "the picture can appear" — it is "the picture appears when it should, its space is already there before it appears, and there is an explanation when something goes wrong".

Views 0Comments 0

Comments (0)

ME
0/1000
Loading comments...
Browse projectsPublish your project

Related articles

Real photograph of a woman typing on a laptop at a desk
Guide
Search Fast, Search Right: The Search Box Playbook for Vibe Projects

The search box looks simple, yet it meets users at their most purposeful moment: a 300 ms debounce, cancelling old requests so they never overwrite new results, empty states with corrections and a way out, match highlighting, full keyboard navigation, Chinese input handling, and a seven-item pre-launch checklist.

Design ExperienceIndie DevelopmentFrontend Engineering
Real photograph of a hand holding a phone showing a list of notifications on the lock screen
Guide
Don't Let the Notice Flash By: Toast Notifications for Vibe Projects

Toasts look trivial, yet they appear at the user's most fragile moments: just deleted something, just paid, just finished an upload. When to show one, how to write the copy, how long to keep it, where to put it, how to make Undo really undo — and five test groups to run before launch.

Design ExperienceIndie DevelopmentFrontend Engineering
Real photograph of a blue vintage alarm clock on a wooden table
Guide
Stop Making Users Do Time Math: Relative Time Display for Vibe Projects

'3 minutes ago' or '2026-11-06T02:53:33.198Z'? A copy-ready threshold table, the UTC-timestamp rule, Intl rendering with tiered refresh, daylight saving and multi-language edges, plus five test groups to run before launch.

Design ExperienceIndie DevelopmentFrontend Engineering