Lazy Loading: Deferral, Prioritization, and CLS

By  Stephan Depta
Last updated October 13, 2026

Lazy Loading is not a rendering mode of its own; it’s a Pro module that plugs into the same SSR/JS decision described in The Rendering Pipeline and into the same reserved-space mechanism every JS-rendered placement already has available. This article covers how LazyLoadService (pro/modules/lazy-loading/class-lazyloading-service.php) wires itself into that architecture: the per-placement mode field, the hard exclusions and why each one is structural rather than arbitrary, the mutually exclusive prioritize counterpart, and the placeholder-dimension mechanism that makes deferring an ad safe in the first place.

The mode field and how it resolves

Every placement carries a single meta field, lazyloading, read by the private mode() method as one of exactly three values: '0' (off), 'lazy_loading', or 'prioritize'. These are not flags that can combine – mode() returns one string, and every other method in the service branches on that single value. An unset or invalid value falls back to 'lazy_loading', which is the module’s documented default: unless an admin explicitly turns it off or switches to prioritization, a placement is lazy-loaded by default once the module is active.

Before that default takes effect, though, mode() runs it through a second check:

mode = placement.meta('lazyloading', default: 'lazy_loading')
mode = mode in ['0', 'lazy_loading', 'prioritize'] ? mode : 'lazy_loading'

if mode == 'lazy_loading' AND placement.type in EXCLUDED_TYPES:
    return '0'   // silently downgraded, never actually deferred

return mode

EXCLUDED_TYPES is a plain constant array — [Header::ID, 'above_title', 'popup'] — not a capability flag on PlacementTypeInterface. The code comment on that constant says why directly: each exclusion has its own structural reason, not a shared one, so a plain list is more honest than a fake abstraction:

  • Header (core/placements/types/class-header.php) hooks its output onto wp_head, not into the document body. It’s registered as a “non-visible header placement” in its own admin bar notification. There is no viewport position for it to scroll into, so a visibility threshold has nothing to measure.
  • Above Title (pro/core/placements/types/class-above-title.php) renders hidden in the footer and is relocated by a DOMContentLoaded script (render_js_mover_in_footer()) to sit immediately before the page’s headline element (target_selector, default h1). That’s by construction at or near the top of the content. There’s no meaningful “distance from the viewport” left to defer to.
  • Popup (pro/core/placements/types/class-popup.php) never occupies a position in the document’s scroll flow. It’s built in the footer and shown as an overlay only when its own trigger fires (popup_trigger: immediate, delay, scroll percentage, post-end, or exit-intent). A presentation system that already owns its own timing logic. A scroll-based lazy-load threshold on top of that would add a second, conflicting timer.

mode() only intercepts the 'lazy_loading' branch for these types – '0' and 'prioritize' pass through untouched. Deferral is structurally meaningless for a Header or Popup placement, but LCP prioritization is a different question the exclusion doesn’t need to touch.

Forcing JS rendering: hooking the same decision as the pipeline

LazyLoadService::hooks() registers maybe_force_js_for_lazy_load() on the adpresso_placement_requires_frontend_logic filter. Whenever mode($placement) === 'lazy_loading', it returns true unconditionally — regardless of whether the placement’s own conditions ever needed frontend evaluation — and logs the reason through RuntimeDebugger::note_js_forcing_reason() for the admin-bar decision log.

This is the same extension point the rendering-pipeline article describes as shared ground between independent modules: lazy loading doesn’t get a special SSR mode of its own. It uses the existing branch—and is inserted into the JS output exactly as it would be in the case of a condition regarding consent to the privacy policy. Because deferring rendering until an element is visible is, mechanically, exactly the “can’t be decided on the server” case that filter exists for. Once active, add_lazy_load_config_to_placement() (hooked on adpresso_placement_frontend_config_data) adds lazy_load_enabled: true and a lazy_load_threshold string built from the admin-configured pixel value (default_threshold(), falling back to 200), formatted as an IntersectionObserver rootMargin ("200px 0px").

On the frontend, lazy-loading.js listens for adpresso:placementInitialized and, for any placement with lazy_load_enabled, inserts a 1px “sentry” element after the placeholder and observes it with a pooled IntersectionObserver — one instance per distinct rootMargin, so placements sharing a threshold share an observer. Only once the sentry intersects does the controller’s proceedWithProcessing() run and the real ad get resolved.

prioritize: the deliberate opposite

Where lazy_loading mode defers, prioritize mode does the opposite: it marks the placement’s resolved item as LCP-critical so the browser fetches it eagerly and with elevated priority instead of waiting for viewport visibility. add_lazy_load_config_to_placement() sets config_data['item']['prioritise'] = true for that mode, which’s priorityImgAttrs() turns into fetchpriority: 'high' (as opposed to the default loading: 'lazy') inside renderImageAd()/renderDummyAd(). is_placement_prioritised() answers the shared adpresso_placement_prioritise_lcp filter so free-tier ad types (Image, Dummy) can react to this without ever reading a Pro placement field directly.

mode() guarantees the two can never both apply to the same placement, and that’s not an arbitrary restriction. They’re opposite answers to the same question: Does this element’s fetch get pushed later, or pulled earlier, relative to the browser’s default timing? Nothing can simultaneously be the thing the page rushes to paint first and the thing it delays until scrolled into view. An admin picks one, the other, or neither ('0') — never both, because there’s no third meaning to express.

The mechanism that makes deferral safe: placeholder dimensions

Due to the CLS metric, none of this is safe to ship without reserving space for the eventual ad. Placement::get_placeholder_html() emits the actual DOM placeholder – a <script type="text/{prefix}-tmpl"> tag, filterable via adpresso.output.placeholder — with its attributes built by FrontendAttributes::for_placeholder(). When the resolved item is an Ad with known width/height (the same dimension source the SSR <img> output uses), that method adds an inline style: display:block; width:100%; max-width:{width}px; aspect-ratio:{width} / {height};.

Without it, an ad resolving after the surrounding content has already been painted would push everything below it down. A shift that gets worse, not better, the longer the deferral runs. With a correctly dimensioned placeholder already occupying the final box before the IntersectionObserver even fires, lazy_loading mode can push rendering arbitrarily far into the future without destabilizing the surrounding layout. Placeholder dimensions are what let this module defer work at all; prioritization is what lets it push the same category of decision the other way. Both sit on the identical frontend_config_data payload the rendering pipeline already builds for every JS-mode placement.

Go deeper

Stephan Depta

About the Author

Stephan Depta is a software engineer and WordPress developer with more than 12 years of experience building plugins, web applications, and custom WordPress solutions. He worked on Advanced Ads for several years, first as Lead Developer and later as Product Manager, combining hands-on plugin development with software architecture, performance optimization, and technical product strategy. Today, he is one of the developers behind AdPresso, where he focuses on building fast, modern, and reliable ad management tools for WordPress publishers.

This is AdPresso

Streamlined WordPress ad management, built on 15 years of expertise for serious monetization.
Features robust protection, targeting, A/B testing, diverse placements, and insightful tracking to spark your full revenue potential.

BlogStudies
AdPresso Logo