Full Page Caching With Personalized Dynamic Content

#

We’ve talked a lot about WordPress performance here at Delicious Brains and the importance of page caching. However, implementing a page cache on highly dynamic sites or sites which display personalized content isn’t always easy. Previously, we’ve covered Microcaching for dynamic content, but that still doesn’t help when personalized content is involved.

In this article we’re going to tackle that issue. We’re going to use Easy Digital Downloads and the Themedd theme to build a fictitious online store. This will present us with a few problem areas that mean we can’t perform page caching out-of-the-box:

  1. The top right navigation displays the current cart totals.
  2. Purchase buttons are replaced with checkout buttons when the item already exists in the cart.

Screenshot of example store

The EDD team provides a simple solution to this problem by setting the edd_items_in_cart cookie when a user has items in their cart. This can then be used to bypass the page cache entirely when the cookie is present. However, we lose all the benefits of page caching for those users:

  1. Blazingly fast load times
  2. Less server strain, due to PHP and MySQL not being hit

This solution doesn’t seem optimal considering such a small part of the page is personalized. In this article we’re going to ensure we can continue to use page caching for all users regardless of whether they have items in their cart. Let’s get started!

Configuring the Cache

Although we’re going to make the majority of the site cache friendly there are still a few URLs that should be bypassed. Generally, any page that contains predominantly unique user content should be excluded. In the case of EDD we want to exclude the following paths:

  • /checkout/
  • /checkout/purchase-confirmation/
  • /checkout/purchase-history/
  • /checkout/transaction-failed/

I’m going to demonstrate using Simple Cache in this article, but any page caching mechanism can be used, including Nginx FastCGI. A simple wildcard rule will ensure those pages are bypassed.

Screenshot of Simple Cache settings screen

Showing Generic Content

For page caching to work correctly we need to ensure that all cached pages are displayed consistently for all users, regardless of whether they have items in their cart. As mentioned previously, there are two areas in the Themedd theme that we need to fix:

  1. The cart in the navigation. This should always show an empty cart.
  2. Purchase buttons are replaced with checkout buttons when an item exists in the cart. We should always output the purchase buttons.

A Generic Cart

First we need to remove the current cart from the navigation. Luckily, Themedd is littered with filters, so we can easily remove the cart. It’s recommended that you don’t modify the Themedd theme and instead create a child theme. The following code is added to the functions.php file.

add_filter( 'themedd_edd_show_cart', '__return_false' );

Next we need to add a non-dynamic version of the cart back into the navigation. We’re essentially copying the original cart code but replacing the dynamic portions so that it always shows an empty cart.

add_action( 'template_redirect',  function() {
    add_action( 'themedd_secondary_menu', function() {
        ?>
        <a class="navCart empty" href="<?php echo edd_get_checkout_uri(); ?>">
            <div class="navCart-icon">
                <svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414"><path fill="none" d="M0 0h24v24H0z"></path><path d="M5.1.5c.536 0 1 .37 1.12.89l1.122 4.86H22.35c.355 0 .688.163.906.442.217.28.295.644.21.986l-2.3 9.2c-.128.513-.588.872-1.116.872H8.55c-.536 0-1-.37-1.12-.89L4.185 2.8H.5V.5h4.6z" fill-rule="nonzero"></path><circle cx="6" cy="20" r="2" transform="matrix(-1.14998 0 0 1.14998 25.8 -1.8)"></circle><circle cx="14" cy="20" r="2" transform="matrix(-1.14998 0 0 1.14998 25.8 -1.8)"></circle></svg>
            </div>
            <span class="navCart-cartQuantityAndTotal">
                <span class="navCart-quantity">
                    <span class="edd-cart-quantity">0</span>
                    <span class="navCart-quantityText"> items</span>
                </span>
                <span class="navCart-total">
                    <span class="navCart-cartTotalSeparator"> - </span>
                    <span class="navCart-cartTotalAmount">$0.00</span>
                </span>
            </span>    
        </a>
        <?php
    } );
}, 10 );

Generic Purchase Buttons

If you dig into the page source you will actually see that both the purchase and checkout buttons are always included in the HTML output for each product, but the display is toggled using inline CSS. This should make it relatively easy to always ensure the purchase button is displayed. Unfortunately, EDD doesn’t have a filter to override the display property (at least from what I can see). This means you need to filter the entire HTML output, which involves parsing the output. The following code will find all links and add or remove display: none; based on the class:

add_filter( 'edd_purchase_download_form', function( $purchase_form, $args ) {
    $html = new DOMDocument();
    libxml_use_internal_errors( true );
    $html->loadHTML( $purchase_form );

    foreach( $html->getElementsByTagName( 'a' ) as $link ) {
        $class = $link->getAttribute( 'class' );

        if ( false !== strpos( $class, 'edd-add-to-cart' ) ) {
            $link->removeAttribute( 'style' );
        }

        if ( false !== strpos( $class, 'edd_go_to_checkout' ) ) {
            $link->setAttribute( 'style', 'display: none;' );
        }
    }

    libxml_clear_errors();

    return $html->saveHTML();
}, 10, 2 );

All of our pages are now cache-friendly. If you go ahead and purge the cache and reload the browser you will always see an empty cart and each product will show the purchase button, even if you have items in your cart.

Loading Personalized Content

We now need to load the personalized content. To do this we’re going to store the user’s cart contents in an additional cookie, which can be accessed on the front-end via JavaScript. It’s important to remember that you should aim to keep cookies as small as possible because they’re transmitted on every page request. With this in mind we’re only going to store the cart quantity, cart total, and product IDs. The cookie will be removed if the cart is empty. Using the init action we can set the cookie if the user has items in their cart, if not remove it.

function themedd_set_cookie() {
    if ( headers_sent() || ! function_exists( 'edd_get_cart_contents' ) ) {
        return;
    }

    $items = edd_get_cart_contents();

    if ( ! empty( $items ) ) {
        $cart = array(
            'quantity' => edd_get_cart_quantity(),
            'total'    => edd_cart_total( false ),
            'items'    => $items,
        );

        setcookie( 'edd_cart', json_encode( $cart ), time() + 30 * 60, COOKIEPATH, COOKIE_DOMAIN );
    } else if ( isset( $_COOKIE['edd_cart'] ) ) {
        setcookie( 'edd_cart', '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN );
    }
}
add_action( 'init', 'themedd_set_cookie', 1 );

With the cookies in place, it’s time to enqueue our custom script file and any dependencies. Because dealing with cookies natively in JavaScript isn’t easy, we’re going to register the JavaScript Cookie package.

function themedd_child_styles() {
    wp_enqueue_style( 'themedd', get_template_directory_uri() . '/style.css' );

    $path = get_stylesheet_directory_uri() . '/assets/js';
    wp_register_script( 'themedd-js-cookie', "{$path}/js.cookie.js", array(), '2.2.0', true );
    wp_enqueue_script( 'themedd-child', "{$path}/main.js", array( 'jquery', 'themedd-js-cookie' ), '1.0.0', true );
}
add_action( 'wp_enqueue_scripts', 'themedd_child_styles' );

The JavaScript component is very simple. First, we check for the presence of the edd_cart cookie. If the cookie exists we update the cart quantity and total cost, else we leave the page as is. Lastly, we loop through the product IDs stored in the cart and replace any corresponding purchase buttons with checkout buttons.

( function( $ ) {
    $( document ).ready( function() {
        if ( ! Cookies.get( 'edd_cart' ) ) {
            return;
        }

        var cart = JSON.parse( Cookies.get( 'edd_cart' ) );

        if ( ! cart.quantity ) {
            return;
        }

        var $navCart = $( '.navCart' );
        $navCart.find( '.edd-cart-quantity' ).html( cart.quantity );
        $navCart.find( '.navCart-quantityText' ).html( cart.quantity === 1 ? 'item' : 'items' );
        $navCart.find( '.navCart-cartTotalAmount' ).html( cart.total );

        $.each( cart.items, function( index, download ) {
            var $form = $( '.edd_purchase_' + download.id );

            $form.find( '.edd-add-to-cart' ).hide();
            $form.find( '.edd_go_to_checkout' ).show();
        } );
    } );
} )( jQuery );

Fixing Inconsistencies

If you add an item to your cart the totals should update in the top right as expected. But, if you refresh the page you once again see an empty cart. Why is that?

If you open the dev tools and check the cookies returned from the server you’ll notice our edd_cart cookie hasn’t been returned. However, if you load the checkout page you’ll see the cookie is present. This happens because we’re setting the cookie on the init hook, which is never hit if the page is cached (because WordPress is bypassed). Although we make an AJAX request to the server to add the item to the cart, the init action fires before the item is added to the cart so the cookie isn’t returned with the AJAX response. The solution is to ensure that the AJAX request responsible for adding a new item to the cart always returns the edd_cart cookie.

Before and after cookies

We can do this by hooking into the edd_post_add_to_cart action and calling the function we created earlier for setting our custom cookie:

add_action( 'edd_post_add_to_cart', 'themedd_set_cookie' );

Now if you open a new Incognito window and add a product to the cart the totals should remain after reloading the page. You may be wondering why we bother hooking into the init action at all, but remember that we need to ensure the cookie is also removed in the following circumstances:

  • A product is deleted from the cart
  • The checkout process is completed

Using init ensures we don’t have to maintain a long list of actions that we need to hook into to determine whether the cookie should be set or removed.

That’s a Wrap

Job done! You can view the final code on GitHub. Although the steps outlined in this article may seem like a lot of work, the difference in load times can be significant. If you’re pressed for time, bypassing the page cache using the edd_items_in_cart cookie is a quick and dirty solution. And if you take the time to implement the optimizations above, visitors will thank you for the improved load times. I’m not going to do a full set of benchmarks, but I will leave you with a simple before and after screenshot of the network panel in Firefox.

Before (no cache)

Before cache load time

After (cached)

After cached load time

That’s almost a 4x improvement! Remember, if the majority of a page contains unique user content it should be excluded from the page cache. Otherwise, it’s worth taking the time to load any personalized content via JavaScript.

Have you used a technique similar to this to improve the speed of your sites? Let us know if the comments below.

About the Author

Ashley Rich

Ashley is a PHP and JavaScript developer with a fondness for hosting, server performance and security. Before joining Delicious Brains, Ashley served in the Royal Air Force as an ICT Technician.