Speed & Performance

WordPress Lazy Loading Images: What Core Does and When It Hurts

WordPress has added loading="lazy" itself since 5.5, and it skips the first three media elements on purpose, because the top one is usually the largest contentful paint element. Here is the real mechanism: the functions, the threshold of 3, fetchpriority, and the places where the logic never runs.

WordPress Lazy Loading Images: What Core Does and When It Hurts

Install a lazy loading plugin on a WordPress site built in the last five years and there is a good chance you have installed something core already does. WordPress has been adding loading="lazy" to images since version 5.5, and it has spent every release since then refining the decision rather than the feature.

The interesting part is not that core lazy loads. It is that core deliberately refuses to lazy load some images, and the rule it uses is worth understanding, because getting it wrong is one of the very few changes that makes a page measurably slower while looking like an optimisation on the checklist.

This is how WordPress lazy loading images actually works in core: the function names, the default values, the filters that change them, and the places where the mechanism never runs at all. Everything here was checked against WordPress 7.0 rather than repeated from a tutorial.

What core does to an img tag

The work happens in wp_filter_content_tags(), in wp-includes/media.php. It is hooked in wp-includes/default-filters.php to the_content at priority 12, deliberately after do_shortcode() has run, and to three more filters at the same priority: the_excerpt, widget_text_content and widget_block_content. That list of four is the entire surface area, and it matters later.

The function runs a regular expression over the HTML looking for img and iframe tags, then walks the matches in the order they appear, because that order decides which images stay eager. For each image it checks the class attribute for the wp-image-123 pattern that the editor writes, and if it finds an attachment ID it hands the tag to a short chain of helpers:

  • wp_img_tag_add_width_and_height_attr() fills in the intrinsic dimensions from the attachment metadata, but only when both attributes are missing.
  • wp_img_tag_add_srcset_and_sizes_attr() builds the responsive candidate list from the registered sizes on disk.
  • wp_img_tag_add_loading_optimization_attrs() decides on loading, fetchpriority and decoding.
  • wp_img_tag_add_auto_sizes() prepends auto to the sizes attribute, but only on images that ended up lazy loaded.

Two things are worth pulling out of that list. The first is that lazy loading and responsive images are decided by the same pass over the same markup, so an image that is missing one is very often missing the other, and the cause is usually the same. The second is the auto sizes step, added in WordPress 6.7: because a lazy loaded image is not requested until layout has happened, the browser already knows how wide the slot is, and sizes="auto, ..." tells it to use that real width instead of the guess the theme wrote. It applies only to lazy loaded images, which is a neat detail, and it can be switched off with the wp_img_tag_add_auto_sizes filter.

Whether the attribute is considered at all comes from wp_lazy_loading_enabled( $tag_name, $context ), which returns true by default for exactly two tag names, img and iframe, and exposes a filter of the same name. Separately, core adds decoding="async" to every image it touches, lazy or not, unless the markup already carries a conflicting value.

There is one hard prerequisite. After the decoding step, wp_img_tag_add_loading_optimization_attrs() checks the markup for width=" and height=" and returns early if either is absent. No dimensions means no loading attribute and no fetchpriority attribute on that image, permanently, even though decoding has already been added. Core wants the dimensions so the browser can reserve the space and avoid a layout shift, and it will not lazy load into an unknown box. If you have never worked out why one upload becomes a dozen files with different dimensions, that is the metadata this step reads from.

The first three media elements are exempt

The decision itself lives in wp_get_loading_optimization_attributes( $tag_name, $attr, $context ), introduced in WordPress 6.4 as the single place where both loading and fetchpriority are worked out together. It is called from the content filter, from wp_get_attachment_image(), and from the iframe helper, so template images and content images go through identical logic.

Inside it, core keeps a running count of media elements on the page using a private counter, wp_increase_content_media_count(), which holds a static integer for the length of the request. Every image and iframe it processes inside the main query loop bumps that counter by one, and before bumping it, core compares the current count against a threshold.

The threshold comes from wp_omit_loading_attr_threshold(), and the default is 3. The first three media elements on the page are treated as probably in the viewport and get no loading attribute at all. From the fourth onwards, everything gets loading="lazy". The function was added in 5.9 with a default of 1, and the default was raised to 3 in WordPress 6.3, after it became clear that a single exemption was too aggressive on layouts that put a small logo or an avatar above the hero.

The value is filterable, and the filter runs only once per page load unless you force it, so it is a page-level setting rather than a per-image one:

// Exempt only the first content media element from lazy loading.
add_filter(
	'wp_omit_loading_attr_threshold',
	static function () {
		return 1;
	}
);

Two refinements sit alongside the counter. Images rendered in a header context are always treated as in viewport regardless of the count: core seeds that list with the header template part area and with get_header_image_tag, and exposes it through the wp_loading_optimization_force_header_contexts filter. And any image that appears before the loop starts, after get_header has fired and before get_footer has, is also treated as in viewport, which covers the banners and page titles that themes print above the content.

Those two cases increase the counter conditionally rather than automatically: an image exempted by header context or by the before-loop rule only consumes one of the three if it is large enough to be a plausible hero. Images inside the loop always consume one. Note also that the counter counts media elements, not images. An embedded video near the top of a post takes one of the three, which is usually correct, since it is genuinely competing for the same bandwidth.

Table of the first five media elements on a WordPress page showing the running count, the loading value and the fetchpriority value core assigns to each, with the hero image marked high priority.

fetchpriority and the LCP candidate

Skipping the loading attribute on the hero image only stops core from making things worse. Since WordPress 6.3 core also does something positive: it marks one image per page with fetchpriority="high".

fetchpriority is a browser hint that changes where a request sits in the fetch queue, not what is fetched. Browsers start most image requests at a low priority, because most images on a page are below the fold, and then raise the priority of the ones layout proves are visible. That correction costs time. Setting fetchpriority="high" on the image you believe is the largest contentful paint element lets the browser skip the wait and pull it in alongside the stylesheet, ahead of the images further down.

Core applies it in wp_maybe_add_fetchpriority_high_attr(), and it is deliberately stingy. The image must have been judged in viewport by the logic above, it must not already be lazy loaded, and it must clear a size floor: width * height has to be at least 50000 square pixels, a value exposed through the wp_min_priority_img_pixels filter. Fifty thousand square pixels is roughly a 300 by 167 image, which is enough to rule out logos, avatars and icons. Once one image qualifies, a private flag is flipped and no other image on the page can claim the attribute. One page, one high priority image.

The two attributes are mutually exclusive by design, and core enforces it. If a filter forces loading="lazy" onto an image that has already been marked fetchpriority="high", core raises a _doing_it_wrong() notice reading “An image should not be lazy-loaded and marked as high priority at the same time.” If you ever see that in a debug log, a plugin or a snippet is fighting core over the hero image.

WordPress 7.0 extended this. Core now also understands fetchpriority="low", used for images that are in the markup but not initially displayed, such as a navigation overlay or a non-initial carousel slide, and fetchpriority="auto", used when a block is shown or hidden depending on viewport width. Both are preserved rather than overwritten. A low priority image is never lazy loaded, because the browser has no way to know when the user will reveal it, and an image marked auto does not increase the media count, so a genuinely visible image further down is not pushed past the threshold by images that may never be shown.

Why lazy loading the hero is worse than doing nothing

Most bad performance advice is merely neutral. Lazy loading the top image is not neutral, it is a regression, and it is worth being precise about why.

Browsers run a preload scanner: a lightweight pass over the raw HTML that starts fetching images, stylesheets and scripts before the main parser has finished, and long before layout exists. An img tag with a plain src is discovered by that scanner within milliseconds of the first bytes arriving. An image marked loading="lazy" is skipped by it, because the whole point of the attribute is to defer the request until the browser knows where the element is and whether it is near the viewport. That knowledge requires the CSS to be downloaded, parsed and applied, and the layout to be computed.

So lazy loading a visible image inserts the entire CSS and layout pipeline in front of a request that would otherwise have started immediately. On a fast connection that is a couple of hundred milliseconds. On a phone on a poor connection it is comfortably more, and it lands directly on largest contentful paint, which is the metric most likely to be the reason someone is reading about lazy loading in the first place.

The saving in exchange is zero. The image is in the viewport, so it is fetched either way. You pay the delay and receive nothing. That asymmetry is why core spends so much code deciding which images to leave alone, and why lazy loading is genuinely valuable for the twentieth image on a long page and actively harmful for the first. The difference is invisible to any tool that only counts how many images carry the attribute.

Where core’s logic never runs

All of the above applies to markup that passes through one of those four filters. Plenty of markup does not, and this is where most real sites lose the behaviour.

Page builders and theme templates. A builder widget or a theme template that composes and echoes its own <img> string never touches the_content, so wp_filter_content_tags() never sees it. Those images get no srcset, no loading, no fetchpriority and no decoding, unless the builder implements its own equivalent. The reliable fix is not a plugin, it is for the template to call wp_get_attachment_image(), which runs the same decision logic directly and therefore participates in the same page-level media count.

Raw HTML in the editor. This one is subtler, because the markup does go through the filter. Paste an <img src="..."> into a Custom HTML block and core will match the tag, then look for the wp-image-123 class, fail to find an attachment ID, and skip the width, height and srcset helpers. The loading helper still runs and still adds decoding="async", but it then checks the markup for width=" and height=", does not find them, and returns early. The result is an image with no responsive candidates and no loading attribute, sitting in a page where everything else has both. Adding the real width and height to that tag by hand restores the loading and fetchpriority half of it immediately.

That shared failure is why the two features go missing together. The mechanism is the same one described from the responsive side in how WordPress actually builds srcset, and if images on a page are missing their candidate list, they are almost certainly missing their loading attribute for exactly the same reason.

Two code panels comparing an editor image that carries a wp-image class with a raw img tag pasted into a Custom HTML block, above a table of which image sources receive srcset, loading and fetchpriority.

Turning it off, per image and globally

The block editor exposes none of this, which is reasonable, because in the common case core’s own guess is better than an editorial one. When you do need to override it, there are three levels.

For a single image rendered from a template, pass the attributes yourself. wp_get_attachment_image() merges the computed values over your array, and a falsy loading value that survives that merge removes the attribute entirely rather than printing an empty one:

echo wp_get_attachment_image(
	$attachment_id,
	'large',
	false,
	array(
		'loading'       => false,
		'fetchpriority' => 'high',
		'alt'           => 'Descriptive alternative text',
	)
);

For images inside post content, the per-image hook is wp_img_tag_add_loading_attr, which receives the computed value, the full image tag and the context. Returning false omits the attribute, and returning lazy or eager sets it. Because it receives the tag as a string you can test it for a class or a filename, which is the usual way to exempt one specific template’s images without touching the rest of the site.

The global switch is blunt and should be treated as such:

// Removes loading="lazy" from every image and iframe on the site.
add_filter( 'wp_lazy_loading_enabled', '__return_false' );

That is almost never the right call on a normal content site, because it also removes lazy loading from the fiftieth image on an archive page, where the attribute is doing genuine work. It is defensible on a landing page with three images total, or temporarily while you diagnose a conflict. In most cases where people reach for it, the actual problem is that the hero is being lazy loaded, and the correct fix is either dimensions on the tag or a threshold adjustment, not a site-wide switch. Wherever you put these snippets, they belong in a place that survives a theme update, which is the argument for a child theme or a small site-specific plugin rather than the parent theme’s functions file.

Whether core got it right on your pages is a question you can answer in a minute. Paste the HTML of a page below, say how many images are visible before anyone scrolls, and the audit walks the document in order.

It flags the lazy attribute on anything above the fold, more than one fetchpriority, images with no dimensions, and the tell tale signs of a lazy loading plugin fighting with core over the same img tag.

Lazy loading auditor

Paste the HTML of a page and see which images, iframes and videos carry loading, fetchpriority and decoding, and which of them sit above the fold, where lazy loading delays the very thing the largest contentful paint measures. The HTML is parsed in a document with no browsing context, so not one image, iframe or script in it is ever fetched, and nothing leaves this browser tab.

Nobody can tell the fold from the HTML alone, so this tool asks instead of guessing. Count what a visitor meets before the first scroll on a phone, the header logo included.

Or drop an .html file
Drop an .html file here
or press Enter to pick one. It is read in this tab and never uploaded.
0Elements
0Errors
0Notices
0Above the fold
Nothing checked yet
#ElementFileAttributesFindings
What to put in functions.php

WordPress 6.3 and later finds the first image of the content by itself, keeps loading="lazy" off it and hands it fetchpriority="high". On most sites that is the whole job and no code is needed. The filter below is for the case this audit still shows: an image above the fold that core never counted, because a page builder, a template part or a widget printed it.


  

Every check runs on the markup only. Whether an element really is above the fold depends on the viewport, so the number you set on the right decides that, not the tool.

WunderPaint
The Dynamic Design and Automation Studio
WunderPaint is a layered image editor for your WordPress media library. WunderPaint Studio is the same thing in any browser, free and without an account.

The plugin question, answered honestly

Most standalone lazy loading plugins were written before WordPress 5.5, when the browser attribute either did not exist or was not widely supported. Their technique reflects that: replace the src with a placeholder, move the real URL into data-src, and swap it back in JavaScript on scroll or through an IntersectionObserver.

That approach hides every image URL from the preload scanner, including the hero, and makes every image wait for JavaScript to parse and execute. It is the problem described above, applied unconditionally and with an extra script in front of it. It was a reasonable trade in 2018. It is not one now.

Running such a plugin alongside modern core is the failure mode worth naming: two implementations both believe they own the same attribute. Core computes fetchpriority="high" for the hero, the plugin rewrites that same tag to data-src and a placeholder, and the high priority hint now points at a one pixel transparent GIF. Or the plugin strips loading before core adds it and core adds it back later, and the outcome depends on hook priorities that neither author coordinated.

So the honest answer: on a current WordPress install, if a plugin’s only job is lazy loading images, you probably do not need it. Deactivate it, load the page, view source, and check that the top image has no loading attribute and that images further down have loading="lazy". If that is what you see, core is doing the job. If the plugin also does things core does not, such as handling CSS background images or providing an exclusion list by URL, keep it and turn off its image lazy loading specifically. Where this sits against everything else you could be doing is covered in the order of things worth fixing on a slow site, and it is rarely at the top.

Iframes and background images

Iframes have been part of this since WordPress 5.7. The same content filter passes them to wp_iframe_tag_add_loading_attr(), which requires all three of src, width and height to be present in the markup and bails otherwise. There is no fetchpriority for iframes, and core does not read their real dimensions at all, it only checks that the attributes exist.

This is quietly the most valuable lazy loading on a typical page. An oEmbed video player pulls in hundreds of kilobytes of script before anyone presses play, and oEmbed markup usually carries all three required attributes, so it gets the attribute automatically. A hand-pasted embed with the width and height stripped out does not.

Background images are the honest gap. A CSS background has no loading attribute and no fetchpriority attribute, because both are properties of an element in the markup, and core does not attempt to manage them. Backgrounds are also already late by nature: the browser cannot request one until the stylesheet has been downloaded, parsed, and matched against an element that is actually rendered. A hero built as a CSS background is therefore both a common largest contentful paint element and one of the slower ones, and the fix is not to lazy load it. Either render it as a real img tag so it becomes discoverable and eligible for fetchpriority="high", or add an explicit <link rel="preload" as="image"> for it in the head.

Flow diagram of a lazy loaded hero image showing the preload scanner skipping the tag, the stylesheet and layout wait, and the request starting late with largest contentful paint delayed.

Symptoms and causes

A testing tool reports that the LCP image is lazy loaded. Either the image is rendered outside the main loop so core’s viewport logic never applied, or a plugin added the attribute unconditionally, or a snippet set loading="lazy" in the template. Find the source and remove it rather than compensating elsewhere.

No image on the page has a loading or fetchpriority attribute at all. The images are missing width and height in the markup, or they are being echoed by a template or builder that bypasses the_content. Check whether the same images also lack srcset: if they do, it is the second cause.

Images near the top of the page pop in a moment after everything else. A JavaScript-based lazy loader is deferring them until its script runs. Core’s attribute does not behave this way for the first three media elements, so if you see it there, something else is doing it.

The debug log says an image should not be lazy-loaded and marked as high priority. That notice comes from core itself when a filter forces loading="lazy" onto the image core already selected as the high priority one. A performance plugin or a snippet on wp_img_tag_add_loading_attr is the usual culprit.

The fourth image on the page is eager and the third is lazy, or something similarly backwards. The media counter counts iframes and header images too, so an embed or a large logo above the content has consumed one of the three exemptions. Raising the threshold slightly is legitimate here.

Nothing below the fold is lazy loaded even though core should handle it. Something has returned false from wp_lazy_loading_enabled, often an optimisation plugin that disables core’s implementation in order to install its own, or a snippet copied from an article written before 5.5 that assumed lazy loading was always a mistake.

What to actually do

The practical version of all this is short. View the source of your most important page and look at the first three image tags. If the top one has no loading attribute and ideally a fetchpriority="high", core is working and you should leave it alone. If it has loading="lazy" or a data-src, you have found a real problem with a measurable cost, and it is worth more than most of the tuning below it on the list.

The deeper point is about where responsibility sits. Core’s implementation is not a heuristic bolted on at the end, it is a page-level state machine: a counter, a threshold, a one-shot priority flag, and a set of contexts that override the count. Anything that renders images outside the_content or outside wp_get_attachment_image() is not merely missing a feature, it is invisible to that state machine, which is why builder-heavy sites so often end up with the priority hint on the wrong element. Routing template images through wp_get_attachment_image() fixes lazy loading, responsive images and layout shift in one move.

And keep the size of the win in proportion. Lazy loading correctly applied saves requests that were never needed; lazy loading incorrectly applied costs a good fraction of a second on the metric you care about. Neither one shrinks the hero image itself, which is usually the larger number by some distance. Getting the attribute right is worth doing because it is cheap and because the failure mode is silent, not because it is the biggest thing on the page. The biggest thing on the page is nearly always the format and quality that image was encoded at.

 

WordPress Lazy Loading Images: What Core Does and When It Hurts

Table of Contents

Learn it by building something

Every week one thing you can make the same afternoon, from dynamic templates to 3D type. Written down step by step.

One mail a week, and then it ends.
Unsubscribe in one click.

Photo Editing

Blend Modes, Actually Explained: Six Formulas Worth Knowing

Most people click through the list until something looks nice. There is a small amount of maths underneath that turns the whole menu into a set of tools.

Troubleshooting

WordPress Maximum Upload Size: Where the Limit Really Lives

The media library says 8 MB, the file is 12 MB, and nothing you edit in WordPress moves the number. It is not a WordPress setting at all: core reads two PHP directives and prints the smaller one. Which limit is really stopping the upload, why post_max_size is usually the culprit, how to raise the limits in the order that works, and why a photograph almost never needs a bigger ceiling.

Troubleshooting

Allowed Memory Size Exhausted: Where the WordPress Memory Limit Lives

The "Allowed memory size exhausted" fatal error involves three separate ceilings, and the one most people raise is not the one that stopped the request. How PHP's memory_limit, WP_MEMORY_LIMIT and WP_MAX_MEMORY_LIMIT interact, and why images trigger the error more often than anything else.

SEO & Structured Data

WordPress Alt Text: Where It Lives and Why Your Edits Do Not Show

Alt text in WordPress is one postmeta row, copied into the post markup the moment you insert an image. That single fact explains why editing the library changes nothing on pages you already published, why an audit has two halves, and what to fix first.

Speed & Performance

Regenerate Thumbnails in WordPress: What It Fixes and What It Leaves Behind

Regenerating thumbnails re-encodes every registered size from the original file. Here is when it is genuinely needed, what each WP-CLI flag really does, why --only-missing quietly keeps the old files, and the order that stops your uploads folder growing.

Security & Privacy

What Your Photos Tell Your Visitors: EXIF, GPS and WordPress

WordPress keeps the file you uploaded exactly as it arrived, sitting next to the resized copies it actually serves. If that file came off a phone, it very likely still carries the coordinates of the room it was taken in.

Download the free WunderPaint Plugin for WordPress

The WunderPaint workspace with the layers panel, adjustment sliders, text style presets and the asset library along the bottom

The Image Editor & Design Studio

Everything described here can be done in the browser, on your own site. The live demo runs the full editor with nothing to install.

Free

Chaos Art

Autonomous painters make one-of-a-kind abstract art in 3D space - gestures, art movements, painterly media, and embeds that paint a new original for every visitor.

Pro

Particle Strokes

Paint with swarms of light: twenty-two movements, a stamp you draw yourself, and curves that give a stroke a shape - the swarm keeps painting for a few seconds after you let go.

Pro

City Diorama

Any place on earth as a miniature you could hold: real streets, water and building footprints raised into a 3D diorama - or wrapped around a sphere as your own tiny planet.

Free

Papercut Art

Layered paper pictures with real depth - a photo sliced along its actual depth into up to twenty layers, parametric landscapes, animals and clouds you can shape, letters with real counters, and a look that runs on three dials.

Pro

3D Earth Studio

A hyperrealistic globe - day and night with city lights, live clouds, atmosphere halo, country borders and highlights, click-to-place markers with flight-route arcs, satellite orbits, seamless rotation video and a live website embed.

Free

Mystic Studio

Turn a birth date into wall art - a real natal chart with houses and aspects, the moon of that night, zodiac and Chinese zodiac posters, numerology cards and a synastry wheel for two, in eight artful themes.

Free

Marble Bath

Marble paper on a virtual water bath - drop, rake and comb real Ebru patterns with gestures, flowers and classic recipes, razor-sharp at any size and re-editable as a layer.

Free

Day Ring

Turn a day into a beautiful circular schedule - colour-coded time blocks as arcs around a 24-hour clock, with concentric rings for overlaps, emoji, templates and a legend.

Free

Code Shot

Turn code into a gorgeous, share-ready image - syntax highlighting, editor themes, window frames and diff highlighting - then drop it into your design as a re-editable layer.

Pro

3D Solar System Studio

Build a date-accurate 3D solar system - real planet positions for any date, photoreal textures and one slider from artistic to true scale - then drop it into your design as an editable layer.

Pro

3D Molecule Studio

Build a real 3D molecule - from a curated library, the periodic table or a SMILES string - then style it, measure it and drop it into your design as an editable layer.

Pro

3D Textile Studio

Drop your design onto cloth that behaves like the material you pick: silk falls soft, felt holds its shape, flag fabric snaps in the wind. Hang it, blow it and drape it, then lay the finished drape back into your document as a picture.

Pro

3D Particle Studio

Point the engine at any layer and it becomes a cloud of particles that keeps its colours, flowing through a sphere, a galaxy or your own outline. Keep the frame you like as a still, or embed the running engine so it keeps moving on your page.

Free

Origami

Put your own picture on the paper and watch that very sheet fold itself into a crane or a box. Every step is a station you can stop at and turn around in 3D, which is exactly where printed diagrams leave you alone.

Pro

3D Flip Studio

A hardcover you can leaf through, a limp magazine, a strewn pile of sheets, a sticker peeling off its backing. The curl is real geometry, so the print never slides across the paper.

Free

Handwriting Fonts

Draw the alphabet here or fill in a printed sheet and photograph it. What comes out is a genuine font family, installed into your site and available in every picker.

Pro

Step Guides

Turn any picture into an instruction. Every mark is pinned to a place in the image, so arrows still point at the right thing after the callout has been dragged somewhere else.