Skip to main content
Being Idea Innovations
WooCommerce Product View Counter: A Version That Actually Scales
Back to Blog

WooCommerce Product View Counter: A Version That Actually Scales

14 November 20186 min readWordPress
Share:

Showing "1,284 views" on a product page is easy to build and easy to build badly. The common implementation has a bug that makes it display zero forever, and a design that degrades the more successful your store becomes.

This guide covers a version that works: correct hooks, current APIs, no personal data, and behaviour that holds up under page caching.

What goes wrong in the usual approach

The widely copied version — including the earlier version of this article — stores every visitor's IP address in a single comma-separated postmeta string:

// DO NOT USE
$meta = get_post_meta( $post->ID, '_views_count', TRUE );
$meta = '' !== $meta ? explode( ',', $meta ) : array();

if ( ! in_array( $user_ip, $meta ) ) {
    array_push( $meta, $user_ip );
    update_post_meta( $post->ID, '_views_count', implode( ',', $meta ) );
}

Five problems, in rough order of severity:

  • It always displays 0. The write uses _views_count and the read uses _se_post_views. Different keys — the display code reads a row that never gets written. This bug has been copied across dozens of blogs.
  • Unbounded growth. Every unique visitor appends ~15 bytes to one string. At 100,000 visitors that is a 1.5 MB postmeta value, loaded, exploded into an array, searched linearly and rewritten on every page view. Popular products get slower the more popular they are.
  • Race conditions. Read-modify-write with no lock. Concurrent visitors overwrite each other's additions, so you undercount exactly when traffic is highest.
  • It stores IP addresses. Under GDPR that is personal data, kept indefinitely, for a vanity counter.
  • It runs on every page. The wp hook fires site-wide, so $post may be any post type or nothing at all.

There is also a deprecated API in there: $product->id was replaced by $product->get_id() in WooCommerce 3.0.

A counter that scales

Store a number, not a list. Use a cookie to decide whether this visitor has already been counted, so you never store anything identifying:

<?php
/**
 * Increment the view count for a single product.
 *
 * Uses template_redirect rather than wp — it fires once, after the query is
 * resolved, only for front-end page loads.
 */
add_action( 'template_redirect', 'bi_count_product_view' );
function bi_count_product_view(): void {
    if ( ! is_singular( 'product' ) || is_admin() ) {
        return;
    }

    // Don't count bots, logged-in shop managers, or preview requests.
    if ( is_preview() || current_user_can( 'edit_products' ) || bi_is_bot() ) {
        return;
    }

    $product_id = get_the_ID();
    $cookie     = 'bi_viewed_' . $product_id;

    if ( isset( $_COOKIE[ $cookie ] ) ) {
        return;   // already counted this visitor recently
    }

    setcookie( $cookie, '1', [
        'expires'  => time() + DAY_IN_SECONDS,
        'path'     => '/',
        'secure'   => is_ssl(),
        'httponly' => true,
        'samesite' => 'Lax',
    ] );

    bi_increment_views( $product_id );
}

/**
 * Atomic increment. Doing this in SQL avoids the read-modify-write race that
 * loses counts under concurrent traffic.
 */
function bi_increment_views( int $product_id ): void {
    global $wpdb;

    $updated = $wpdb->query( $wpdb->prepare(
        "UPDATE {$wpdb->postmeta}
            SET meta_value = meta_value + 1
          WHERE post_id = %d AND meta_key = '_bi_view_count'",
        $product_id
    ) );

    if ( ! $updated ) {
        add_post_meta( $product_id, '_bi_view_count', 1, true );
    }

    wp_cache_delete( $product_id, 'post_meta' );
}

function bi_is_bot(): bool {
    $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
    return $ua === '' || (bool) preg_match( '/bot|crawl|spider|slurp|bingpreview/i', $ua );
}

The UPDATE … SET meta_value = meta_value + 1 is the key change. The database performs the addition, so two simultaneous views produce two increments rather than one. Note the wp_cache_delete() — without it, a persistent object cache keeps serving the stale value.

A single integer replaces a string that grew forever, and no personal data is stored anywhere.

Displaying the count

function bi_get_view_count( int $product_id ): int {
    return (int) get_post_meta( $product_id, '_bi_view_count', true );
}

function bi_render_view_count(): void {
    global $product;

    if ( ! $product instanceof WC_Product ) {
        return;
    }

    // get_id(), not ->id — the property was removed in WooCommerce 3.0.
    $views = bi_get_view_count( $product->get_id() );

    if ( $views < 10 ) {
        return;   // "3 views" makes a product look unloved — hide low counts
    }

    printf(
        '<div class="bi-views">%s<span class="bi-views__value">%s</span></div>',
        bi_eye_icon(),
        esc_html( sprintf(
            /* translators: %s: formatted view count */
            _n( '%s view', '%s views', $views, 'being-idea' ),
            number_format_i18n( $views )
        ) )
    );
}

function bi_eye_icon(): string {
    return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
        stroke-width="1.8" aria-hidden="true" focusable="false">
        <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
        <circle cx="12" cy="12" r="3"/></svg>';
}

// Single product page, after the add-to-cart button
add_action( 'woocommerce_after_add_to_cart_button', 'bi_render_view_count', 15 );

// Shop and category archives, after each product in the loop
add_action( 'woocommerce_after_shop_loop_item', 'bi_render_view_count', 15 );

Using an inline SVG rather than <i class="fa fa-eye"> removes the Font Awesome dependency entirely — the icon inherits your text colour and costs no extra request.

number_format_i18n() formats according to the site's locale, so you get 1,284 or 1.284 as appropriate rather than a bare integer.

Hook priorities

The original used priority 0 on woocommerce_after_add_to_cart_button, which runs before nearly everything else attached to that hook. Priority 10 is the default; a slightly higher number places your output after other plugins' additions. Also note the original named its shop-loop callback show_free_shipping — a copy-paste leftover that makes the code confusing and risks colliding with a genuinely named function elsewhere.

Page caching

This is the part that surprises people. With full-page caching — which any serious WooCommerce store runs — template_redirect only fires on a cache miss. Most visitors are served static HTML and never reach PHP, so they are never counted.

Two options:

Accept the undercount. If the number is decorative social proof, a consistent undercount is harmless.

Count over AJAX. Fire a small request from the browser, which runs regardless of page caching:

add_action( 'wp_ajax_bi_count_view', 'bi_ajax_count_view' );
add_action( 'wp_ajax_nopriv_bi_count_view', 'bi_ajax_count_view' );

function bi_ajax_count_view(): void {
    check_ajax_referer( 'bi_view_nonce', 'nonce' );

    $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0;

    if ( $product_id && 'product' === get_post_type( $product_id ) ) {
        bi_increment_views( $product_id );
    }

    wp_send_json_success();
}
navigator.sendBeacon(
  BI.ajaxUrl,
  new URLSearchParams({ action: 'bi_count_view', product_id: BI.productId, nonce: BI.nonce })
);

sendBeacon is right here — it is fire-and-forget, does not delay page rendering, and still completes if the visitor navigates away immediately.

Sorting by popularity

Because the count is a single numeric meta value, you can order by it:

add_filter( 'woocommerce_get_catalog_ordering_args', function ( array $args ): array {
    if ( isset( $_GET['orderby'] ) && 'views' === $_GET['orderby'] ) {
        $args['meta_key'] = '_bi_view_count';
        $args['orderby']  = 'meta_value_num';
        $args['order']    = 'DESC';
    }
    return $args;
} );

That would have been impossible with the comma-separated string, since you cannot sort on the length of a text field without a full scan.

Add an index if your catalogue is large — WordPress indexes meta_key but the combination matters for sorting:

CREATE INDEX idx_bi_views ON wp_postmeta (meta_key(20), meta_value(10));

Summary

  • Store an integer, not a growing list of visitors.
  • Increment in SQL so concurrent views are not lost.
  • Use a cookie for deduplication instead of storing IP addresses.
  • Hook template_redirect with an is_singular('product') guard, not wp.
  • Use $product->get_id(); the ->id property is long gone.
  • Make sure the meta key you write is the one you read.
  • Count over AJAX if you run page caching.
Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers