Performance

Why loyalty points do not belong in postmeta

By · ·9 min read
Why loyalty points do not belong in postmeta — NeoPlugin

Every WooCommerce loyalty plugin has to answer one question before it writes a line of code: where does a customer's point balance live?

The convenient answer is to reuse what WordPress already gives you — a row in wp_usermeta, a key in wp_postmeta, or a serialised blob in wp_options. It works on a demo store. It is also the reason so many WooCommerce loyalty points plugins become the slowest thing on a catalog once the store is real.

The short version

  • A WooCommerce loyalty plugin is a database design problem wearing a marketing hat. Where the points are stored matters more to your shop's speed than how many earning rules the plugin offers.
  • wp_options charges every page. Anything autoloaded is read into memory before WordPress knows whether the page needs it, so a growing points structure there bills your blog posts for a feature only checkout uses.
  • wp_postmeta puts your points behind your orders. It is the table WooCommerce leans on hardest, and a points ledger in it competes with the queries your shop depends on.
  • wp_usermeta holds a number, not a history. One value per user per key gives you a balance with nothing behind it — no way to answer why a balance is what it is.
  • NeoGrow uses its own table, wp_neogrow_points_log — an append-only ledger where a balance is the sum of its rows, with four indexes chosen for the queries the plugin actually runs.
  • It is the same move WooCommerce made when it took orders out of the posts table, and NeoGrow declares compatibility with HPOS and with the block checkout.
  • Two things to know before installing: the Growth Dashboard analytics are cached for an hour, and nothing is backfilled from past orders.

What the convenient answer actually costs

wp_options is loaded, in part, on every single request. Anything marked autoload is pulled into memory before WordPress knows whether the page even needs it. A loyalty plugin that parks a growing structure there is charging your blog posts for a feature only your checkout uses.

wp_postmeta has the opposite problem. It is one enormous key-value table shared by every plugin on the site, and it is already the table WooCommerce leans on hardest. Adding point transactions to it means your loyalty queries and your order queries compete for the same indexes.

wp_usermeta fails differently again. It is built to hold one value per user per key, which is fine for a balance and useless for everything around it: a single number carries no record of how it got there, so a refund, a correction, or a customer asking why they have 340 points has nothing to read back.

Where the balance livesWhat the table was built forWhat that means for your shop
wp_optionsSite-wide settings, the autoloaded ones read as a block on every requestA growing points structure is read into memory on pages that will never use it
wp_postmetaKey-value attributes hung off posts and ordersPoints queries contend with the order queries your checkout depends on
wp_usermetaOne value per user, per keyA balance with no history behind it — nothing to audit, reconcile or explain
A custom tableA transaction ledger, and nothing elseIts own indexes, its own table, no autoload, and no contention with WooCommerce

Neither is a bug, exactly. Both are a plugin borrowing a table that was designed for something else.

What NeoGrow does instead

NeoGrow keeps points in a custom table, wp_neogrow_points_log. It is an append-only ledger: every earn and every redemption is a row, points is a signed integer, and a customer's balance is the sum of their rows. Nothing is overwritten, so the history is the data rather than something maintained alongside it.

A row records the customer, the signed points, an event type, a reference — the order ID, stored as a bigint to match WooCommerce's own order IDs rather than as a string — a log entry, and a timestamp.

Four indexes sit on it, and each one earns its place:

  • user_id — a customer's balance.
  • (event_type, ref_id) — has this order already been credited?
  • (user_id, created_at) — the My Account points history, and the batched

expiry sweep, which groups by customer while comparing dates.

  • created_at on its own — because a B-tree cannot serve an ordering or a

range on a non-leading column. The admin points log sorts every page by date with no customer in the predicate, and the dashboard chart filters on it. Both were a full table scan plus a filesort until this index existed.

That last one is the argument in miniature. It is a narrow fix for two specific queries, and it is only available to a plugin that owns its table. Nobody gets to add an index to wp_postmeta because their loyalty points need one.

The table buys three things:

  • Point lookups never touch wp_postmeta, so they do not compete with order

queries. Checkout stays responsive on stores with thousands of active customers.

  • The schema is designed for the job — a transaction log with its own indexes,

rather than string keys in a generic bag.

  • The ledger is never autoloaded. It cannot grow into the autoload payload,

because it is not in wp_options at all, so a page that does not use points pays nothing for them.

It is the same reason WooCommerce itself moved orders out of the posts table with HPOS: transactional data outgrows a general-purpose key-value store, and the fix is a table shaped like the thing you are storing. NeoGrow declares compatibility with HPOS and with the block-based cart and checkout, so it runs on either order storage mode. Generic storage is fine until volume makes the mismatch expensive.

Where the analytics go

There is one deliberate trade-off worth knowing about. The Growth Dashboard statistics — the chart and the summary cards — are cached for one hour.

Your live points data is always current: a balance shown to a customer, or the Points Log on a user profile, is read straight from the table. It is only the aggregate analytics view that lags. Recomputing store-wide totals on every admin page load is exactly the kind of thing that makes wp-admin crawl, and an hour-old chart is a fair price for an admin that stays fast.

The part people discover too late

NeoGrow is not retroactive. It starts tracking from the moment you activate it, and it will not scan your order history to backfill points for purchases that already happened.

That is a design decision, not an oversight. Backfilling means walking every historical order on a live site and writing a transaction for each one, which is precisely the sort of migration that takes a store down at the wrong moment.

If you want to recognize long-standing customers, Users > Edit User lets you set a starting balance by hand, and the adjustment is written to the Points Log with your reason attached. Manual adjustment is also how refunds are handled in the free version — the full lifecycle of a point is here.

Frequently asked questions

Where should a WooCommerce loyalty plugin store points?

In a dedicated custom table, not in generic WordPress metadata. wp_usermeta, wp_postmeta and wp_options were built for settings and attributes, and a growing points ledger in any of them competes with the queries your shop actually depends on. NeoGrow keeps WooCommerce loyalty points in wp_neogrow_points_log, an append-only ledger with four indexes chosen for the queries it runs.

Will a loyalty points plugin slow down WooCommerce?

It can, and where it stores balances is the reason. A plugin that autoloads points data from wp_options makes every page on the site pay for a feature only checkout uses, and one that writes point transactions into wp_postmeta puts them in the same table WooCommerce leans on hardest. Point lookups in NeoGrow never touch wp_postmeta, so they do not compete with order queries.

What does the NeoGrow points table look like?

wp_neogrow_points_log is an append-only ledger. Each row holds the customer, a signed points figure, an event type, a reference to the order as a bigint matching WooCommerce's own order IDs, a log entry and a timestamp — and a balance is the sum of a customer's rows. It carries four indexes: user_id, (event_type, ref_id), (user_id, created_at), and created_at on its own, the last because the admin log sorts by date with no customer in the predicate and a B-tree cannot order on a non-leading column.

Does NeoGrow import points from past orders?

No. NeoGrow is not retroactive — it starts tracking from the moment you activate it and will not scan order history to backfill points. Backfilling means walking every historical order on a live site and writing a transaction for each one, which is the kind of migration that takes a store down at the wrong moment. To recognize long-standing customers, set a starting balance by hand under Users > Edit User; the adjustment is written to the Points Log with your reason attached.

Is the points balance shown to customers ever out of date?

No. Balances and the Points Log are read straight from the table, so what a customer sees is current. Only the aggregate Growth Dashboard analytics — the chart and summary cards — are cached, for one hour, so that recomputing store-wide totals does not slow down wp-admin.

Which table do the points end up in?

A WooCommerce loyalty plugin is a database design problem wearing a marketing hat. If you are evaluating one, the useful question is not how many earning rules it has — it is which table the points end up in, and whether the plugin can add an index to it when a query needs one. For everything else worth weighing, see the complete WooCommerce loyalty program guide.

You can read the NeoGrow documentation for the full schema and hook reference, or get the plugin free from WordPress.org.

Share X LinkedIn Facebook Email
woocommercehposperformancedatabase

Keep reading

The WooCommerce Product Reviews block skips every review hook

Review photos and badges missing? The WooCommerce Product Reviews block fires no woocommerce_review_* hooks. How to check, and a tested fix.

WooCommerce review request email: how to switch it on (and the three traps)

Turn on WooCommerce's built-in review request email: the two switches, the Delay setting, the Review Order page, and the traps to check first.

Reward customers in every currency you sell in

Multi-currency orders distort loyalty points. NeoGrow anchors WooCommerce rewards to base currency for WooPayments, CURCY and FOX.