steve hitchman

developer, design tinkerer, entrepreneur, father, avid geocacher and gamer.

Speed conscious

Posted: 01/08/2018

Making WooCommerce fly and forward thinking.

I've recently been doing a great deal of performance audits for a variety of clients and one of the most recent was a large WooCommerce website, following an initial review we first decided to move the site from a shared web host to it's own VPS with a tightly configured LEMP stack.

Before moving the site nearly every page was in the 3s to 15s range for overall load time and all requests exhibited over 1s TTFB (Time to First Byte) which for an online shop is not good enough, the move to a VPS and some configuration tweaks brought all TTFB times under on 1s and full page loads down to the 1s to 3s range but there was two exemptions, the cart and checkout.

The cart and checkout were producing a TTFB between 5s and 15s, obviously something was seriously wrong here after some digging we came across this piece of code, after a quick discussion with the client following are review I discovered this piece of code was setup to establish if the system should automatically add a defined coupon if the this was the customers first order with them.

function has_bought() {
    // Get all customer orders
    $customer_orders = get_posts( array(
        'numberposts' => -1,
        'meta_key'    => '_customer_user',
        'meta_value'  => get_current_user_id(),
        'post_type'   => 'shop_order', // WC orders post type
        'post_status' => 'wc-completed' // Only orders with status "completed"
    ) );
    // Count number of orders
    $count = count($customer_orders);

    // return "true" when customer has already one order
    if ($count >= 1)
        return true;
    else
        return false;
}

At first glance this seems fairly harmless but once you start digging deeper there are a few very noticeable issues so lets pull things apart and establish exactly what it needs to do, first we need to establish if a user is logged in (this piece of code doesn't take care of that), then we need to see if they've ever made an order with us and if they have return true so the method calling knows if it should add the coupon or not.

To start of lets add a check to see if a customer is logged in and return early if they aren't.

function has_bought() {
    // if they aren't logged in return early...
    if (!is_user_logged_in()) {
        return false;
    }

    // Get all customer orders
    $customer_orders = get_posts( array(
        'numberposts' => -1,
        'meta_key'    => '_customer_user',
        'meta_value'  => get_current_user_id(),
        'post_type'   => 'shop_order', // WC orders post type
        'post_status' => 'wc-completed' // Only orders with status "completed"
    ) );
    // Count number of orders
    $count = count($customer_orders);

    // return "true" when customer has already one order
    if ($count >= 1)
        return true;
    else
        return false;
}

This small change made a considerable difference to any customer who wasn't logged in because with this minor amendment meant it would no longer run any queries for a guest but we can still improve things further, one big thing that jumped out was the fact that the query is loading an unlimited amount of posts yet we only ever need to query for one to establish if one order exists, secondly we can use WP_Query directly and also define only the fields we want so let's take a look and what we end up with after these changes.

function has_bought()
{
    // return early if the customer isn't logged in
    // because if they are a guest they haven't made an order before
    if (!is_user_logged_in()) {
        return false;
    }

    $args = [
        'numberposts' => 1,                      // we only need one post
        'fields'      => 'ids',                  // we don't need all fields
        'post_type'   => 'shop_order',
        'post_status' => 'wc-completed',
        'meta_key'    => '_customer_user',
        'meta_value'  => get_current_user_id(),
    ];
    $customerOrders = new WP_Query($args);

    // we can now use the WP_Query object property post_count
    if ($customerOrders->post_count <= 0) {
        return false;
    }

    return true;
}

So following the changes did it make any difference? Yes, a huge difference some numbers to confirm.

TTFB, before: 5 seconds

TTFB, after: 500ms

Memory consumption, before: 60mb

Memory consumption, after: 10mb

What's the lesson here, well I didn't write this initial piece of code but always take into consideration the potential amount of data any given query is going to be loading, this piece of code will have continually gotten slower to process and consumed more memory as orders continued coming in especially for a guest user.