The Rendering Pipeline: SSR vs. JS Output Per Placement

By  Stephan Depta
Last updated September 17, 2026

For every placement, on every request, AdPresso makes one central decision: render the ad server-side (SSR) and write it straight into the delivered HTML, or resolve it in the visitor’s browser instead? This decision is the core of AdPresso’s rendering pipeline – not a bolted-on caching feature, but the architecture that targeting, fallbacks, lazy loading, and several other modules are all built on top of. This article walks through how that decision gets made and where it happens in the request lifecycle.

Why this decision exists at all

A placement doesn’t necessarily show the same thing to every visitor. Targeting conditions – device type, browser language, consent status, and so on – are sometimes only known in the browser; while PHP is handling the request on the server, those signals aren’t available yet. For a placement whose output depends on one of them, the server can’t make a final call on what to show. For everything else, it can—and does —decide directly, with no extra round trip.

The pipeline doesn’t treat this as an edge case. It’s the default branch: for every placement, AdPresso checks whether a visitor-dependent decision is required, then picks the output path accordingly.

The decision tree

Shown schematically below (not a line-for-line copy of the actual class):

for each published placement:
    item = resolve the assigned ad or group

    needs_js = placement.requires_frontend_logic
            OR item.requires_frontend_logic

    needs_js = apply_filters( 'adpresso_requires_frontend_logic', needs_js, placement, item )

    if needs_js:
        build a frontend_config payload (item data, condition JSON, fallback chain)
        → handed to the browser later, in the footer
    else:
        call item.render_ssr()
        → resulting HTML is written straight into the page

requires_frontend_logic isn’t a single flag – it’s the outcome of evaluating the condition tree. If the condition tree on a placement or its assigned item contains at least one condition that can only be evaluated in the browser (execution context frontend_js/frontend_ajax — see the conditions reference), the decision tips toward JS output.

Entry points in the request lifecycle

The pipeline runs exactly once per request, centrally orchestrated by Pipeline\Runtime:

  • Runtime::maybe_execute_processor(), hooked on wp at priority 5 – after the main query has resolved and the current user is known, but before any template output. This method instantiates AdPipelineProcessor and calls its process_context().
  • AdPipelineProcessor::process_context() iterates every published placement, resolves the assigned item for each one, and applies the decision tree above. The result is an array keyed by placement ID, each entry carrying an output_mode (ssr or js) plus the corresponding data – either rendered HTML or a config payload.
  • Runtime::localize_frontend_data(), hooked on wp_footer, collects the config payloads for every placement in JS mode and hands them to the frontend script via wp_localize_script, as adpressoData.placementConfig. Only there – in the browser, after the page has loaded – does the JS pipeline pick the item that actually gets shown, based on the condition JSON it was given.

Because the processor runs once per request and caches its own result, it doesn’t matter how many times or from which theme template a placement ends up being output — the SSR/JS decision itself is never made twice.

Extensibility: modules hook into the same decision

The adpresso_requires_frontend_logic filter is the central extension point for this branch. Several independent modules use it to force JS output for their own, unrelated reasons – regardless of whether the placement or item itself carries a frontend-only condition:

  • A privacy/consent module can force JS output because the visitor’s consent state is only known in the browser and has to be checked before anything renders.
  • A lazy-loading module can force JS output because actual rendering needs to be deferred until the placeholder becomes visible in the viewport – something that can’t be anticipated server-side either.

Both cases go through the same filter and the same decision tree, not a separate special-case path. This is included here as evidence of how extensible the architecture is, not as a how-to for implementing your own filter callback – details on hooking your own conditions or rendering logic belong in the filters/actions reference.

The fallback chain

Both the SSR and JS paths follow the same fallback order if the primary assigned item can’t be delivered (invalid, display limit reached, no consent): first a placement-specific privacy fallback, then a placement-specific general fallback, and finally nothing at all. In JS mode, the fallback chain is included in the config payload so the browser can apply it without an extra server round trip.

Side effect: cache compatibility

Because a visitor-dependent placement never ships fully-resolved HTML in the server response – only a generic config payload – the page the server delivers (and that a full-page cache like WP Rocket or LiteSpeed Cache stores) contains nothing visitor-specific at that spot. The actual selection happens afterward, per visitor, in the browser. A full-page cache plugin doesn’t need to do or configure anything special for this. It’s a consequence of the architecture described above, not a dedicated cache integration.

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