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 onloading,fetchpriorityanddecoding.wp_img_tag_add_auto_sizes()prependsautoto thesizesattribute, 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.
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.
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 fileor press Enter to pick one. It is read in this tab and never uploaded.
| # | Element | File | Attributes | Findings |
|---|
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.
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.
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.