Diagnostic WooCommerce core: database bloat and maintenance

Autoloaded options bloat

Every page on your store, the storefront, the cart, even a plain REST API call, loads a bundle of "autoloaded" settings before it does anything else. Too many autoloaded options, or a few very large ones, and every one of those pages gets slower. This is a quiet problem because nothing errors out. It just gets a little heavier every week. Here is why it happens and a small script that finds the big rows and trims the ones that are safe to trim.

Python and Node.js Runs on a schedule Safe by default (dry run)
A screenshot of a computer screen with a web page on it
Photo by Team Nocoloco on Unsplash
The short answer

WordPress loads every option marked autoload='yes' on every single page load. Plugins, including the Stripe gateway, write small per-order records while a payment is processing and some of those never get their autoload flag turned off, so the pile only grows. Run a small Python or Node.js job on a schedule that lists the biggest autoloaded rows, matches the Stripe-related ones to their order and PaymentIntent, and demotes to autoload='no' only the ones tied to orders that are truly finished. Full code, tests, and a dry run guard are below.

The problem in plain words

WordPress keeps a table called wp_options for settings, plugin data, and small cached values. Each row has an autoload column. When it is set to "yes", WordPress pulls that row into a single cache called alloptions and loads the whole thing into memory before your page even starts rendering, on every request.

Most rows are tiny and this costs nothing. The trouble starts when a plugin writes something large, a serialized array, a cached rate table, a per-order processing record, and leaves autoload on. WooCommerce gateways like Stripe save short-lived records for every order in progress: an idempotency key so a retried request cannot double charge, a lock so two workers do not process the same webhook twice, sometimes a cached snapshot of the PaymentIntent. Once the order is done these rows serve no purpose, but nothing goes back to clean them up or turn autoload off. A year of orders later, the autoloaded payload can be several megabytes, and it never really goes away on its own.

Order pays Stripe writes a lock row Row saved autoload = yes never cleaned up Order finishes row is now dead weight Thousands of orders later alloptions cache grows to megabytes Every page loads slower
Each order in progress adds a small autoloaded row. Nothing removes it when the order finishes, so the pile that every page must load in memory keeps growing.

Why it happens

WordPress core and the WooCommerce team have both written about this pattern. A few common reasons the pile builds up on a WooCommerce store specifically:

None of this throws an error, which is exactly why it is easy to miss. The site just gets slower, a little at a time, until a page speed report or a slow admin dashboard finally makes someone go looking.

The key insight

An autoloaded option only deserves to stay autoloaded if it is read on most page loads. A per-order Stripe lock is read exactly once, when that order's webhook arrives, and never again after the order is finished. Once you can prove an order and its Stripe PaymentIntent are both done, the option tied to it has no reason to keep costing every future page load anything.

The fix, as a flow

We do not touch live checkout code. We add a job that runs on a schedule, asks the store for a list of autoloaded options above a size threshold, and for each one that looks like a Stripe per-order record, checks whether the order and its Stripe PaymentIntent have both reached a finished state. If they have, the option's job is done, so we demote it to autoload='no' instead of deleting it outright, which keeps the data around for the rare case someone needs to look it up by hand.

Scheduled job weekly or monthly List big autoloaded options above threshold Match order id from the option name Order and intent finished? yes no, keep it Demote option autoload set to no
The job only demotes an option once both the order and its Stripe PaymentIntent agree the payment is fully settled. Anything still active is left exactly as it is.

Build it step by step

1

Get access, and add one small read-only endpoint

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. WordPress core has no built in REST route for autoloaded option sizes, so add a small custom endpoint (a few lines in a must-use plugin) that queries wp_options for rows where autoload='yes' and reports their name and byte length. Keep every credential in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export MIN_BYTES="10000"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export MIN_BYTES="10000"
export DRY_RUN="true"   // start safe, change to false to write
2

List the oversized autoloaded options

Ask the read-only endpoint for every autoloaded option above the size threshold. We only care about rows whose name matches the pattern Stripe order records use, something like _wc_stripe_idempotency_1042 or _wc_stripe_intent_1042, where the trailing digits are the WooCommerce order ID. Anything else is left alone completely, since we cannot safely reason about what another plugin's option is for.

step2.py
import re

ORDER_OPTION_RE = re.compile(r"^_wc_stripe_(?:idempotency|intent|lock)_(\d+)$")

def order_id_from_option(option_name):
    match = ORDER_OPTION_RE.match(option_name)
    return int(match.group(1)) if match else None

def autoloaded_options(min_bytes):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc-tools/v1/autoloaded-options",
        params={"min_bytes": min_bytes}, auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const ORDER_OPTION_RE = /^_wc_stripe_(?:idempotency|intent|lock)_(\d+)$/;

function orderIdFromOption(optionName) {
  const match = ORDER_OPTION_RE.exec(optionName);
  return match ? Number(match[1]) : null;
}

async function autoloadedOptions(minBytes) {
  const res = await fetch(
    `${WOO_URL}/wp-json/wc-tools/v1/autoloaded-options?min_bytes=${minBytes}`,
    { headers: { Authorization: AUTH } },
  );
  if (!res.ok) throw new Error(`autoloaded-options returned ${res.status}`);
  return res.json();
}
3

Load the matching order and its Stripe PaymentIntent

Use the WooCommerce REST API to read the order by the ID pulled from the option name. Read the PaymentIntent ID from the order's _stripe_intent_id meta field, falling back to transaction_id when it looks like a PaymentIntent (it starts with pi_). Then ask Stripe for that PaymentIntent directly, since Stripe is the source of truth for whether the payment itself is really finished.

step3.py
def intent_id_of(order):
    for meta in (order or {}).get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = (order or {}).get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None

def get_order(order_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()

def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
function intentIdOf(order) {
  for (const meta of (order && order.meta_data) || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order && order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the option row, the order, and the intent, and returns an action. This is the part worth testing carefully, since it decides what gets touched in the database. The rule: skip anything below the size threshold or not matching our naming pattern, flag an orphan if the order is gone, keep it if the order or the PaymentIntent is still active, otherwise demote it.

decide.py
FINISHED_ORDER_STATUSES = {"processing", "completed", "refunded", "cancelled", "failed"}
FINISHED_INTENT_STATUSES = {"succeeded", "canceled"}

def decide(option, order, intent):
    if option.get("bytes", 0) < MIN_BYTES:
        return ("skip", "below the size threshold")
    order_id = order_id_from_option(option["option_name"])
    if order_id is None:
        return ("skip", "not a Stripe order option")
    if order is None:
        return ("orphan", f"order {order_id} no longer exists")
    if order["status"] not in FINISHED_ORDER_STATUSES:
        return ("keep", "order is still active")
    if intent is not None and intent.get("status") not in FINISHED_INTENT_STATUSES:
        return ("keep", "Stripe PaymentIntent is still active")
    return ("demote", "order and PaymentIntent are both finished")
decide.js
const FINISHED_ORDER_STATUSES = new Set(["processing", "completed", "refunded", "cancelled", "failed"]);
const FINISHED_INTENT_STATUSES = new Set(["succeeded", "canceled"]);

export function decide(option, order, intent) {
  if ((option.bytes || 0) < MIN_BYTES) return ["skip", "below the size threshold"];
  const orderId = orderIdFromOption(option.option_name);
  if (orderId === null) return ["skip", "not a Stripe order option"];
  if (!order) return ["orphan", `order ${orderId} no longer exists`];
  if (!FINISHED_ORDER_STATUSES.has(order.status)) return ["keep", "order is still active"];
  if (intent && !FINISHED_INTENT_STATUSES.has(intent.status)) {
    return ["keep", "Stripe PaymentIntent is still active"];
  }
  return ["demote", "order and PaymentIntent are both finished"];
}
5

Demote the option instead of deleting it

When the action is demote, call the same custom endpoint to flip autoload to "no" for that one row. The row stays in the database for anyone who needs to look it up, it just stops being pulled into memory on every page load. This is a smaller, safer change than deleting the row outright.

apply.py
def demote(option_name):
    requests.post(
        f"{WOO_URL}/wp-json/wc-tools/v1/autoloaded-options/demote",
        json={"option_name": option_name},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function demoteOption(optionName) {
  const res = await fetch(`${WOO_URL}/wp-json/wc-tools/v1/autoloaded-options/demote`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: AUTH },
    body: JSON.stringify({ option_name: optionName }),
  });
  if (!res.ok) throw new Error(`demote ${optionName} returned ${res.status}`);
}
6

Wire it together with a dry run guard

The loop ties every piece together and adds up how many kilobytes the run would free from every future page load. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports its plan. Read the output, trust it, then switch it off. Run it on a schedule, once a week or once a month is plenty.

Run it safe

Always start with DRY_RUN=true. The job writes to real option rows, so you want to see its plan before it acts. Once the report looks right, turn it off.

The full code

Here is the complete job in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever touches an option once both the order and its Stripe PaymentIntent agree the payment is fully settled.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

find_stale_autoload.py
"""Find stale, oversized autoloaded wp_options rows left behind by Stripe order
processing, and report which ones are safe to demote to autoload='no'.

WooCommerce Stripe gateways write small per-order records while a payment is in
flight: an idempotency lock, a processing flag, a cached PaymentIntent snapshot.
Some of these are saved with autoload left at the default of "yes", so every single
page load, including the storefront, pulls them into the alloptions cache. Once the
order is finished they serve no purpose, but nothing ever cleans them up, so the
autoloaded payload only grows.

This script reads a custom, read-only endpoint you add to your store
(wp-json/wc-tools/v1/autoloaded-options) that lists autoloaded options above a size
threshold, matches the Stripe-related ones back to their order through the order id
encoded in the option name, checks the order and its Stripe PaymentIntent are both
finished, and reports (or repairs) the ones safe to demote. Read only by default.
"""
import os
import re
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_stale_autoload")

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
MIN_BYTES = int(os.environ.get("MIN_BYTES", "10000"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Matches option names such as _wc_stripe_idempotency_1042 or
# _wc_stripe_intent_1042, where the trailing digits are the WooCommerce order id.
ORDER_OPTION_RE = re.compile(r"^_wc_stripe_(?:idempotency|intent|lock)_(\d+)$")

FINISHED_ORDER_STATUSES = {"processing", "completed", "refunded", "cancelled", "failed"}
FINISHED_INTENT_STATUSES = {"succeeded", "canceled"}


def order_id_from_option(option_name):
    """Pull the WooCommerce order id out of a Stripe-related option name, or None."""
    match = ORDER_OPTION_RE.match(option_name)
    return int(match.group(1)) if match else None


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in (order or {}).get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = (order or {}).get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def decide(option, order, intent):
    """Pure decision: what to do with one autoloaded option row.

    option is a dict with at least "option_name" and "bytes".
    order is the matching WooCommerce order dict, or None if not found.
    intent is the matching Stripe PaymentIntent dict, or None if not found.

    Returns a (action, reason) tuple. action is one of:
      "skip"   - leave it alone, not ours or below the size threshold
      "keep"   - it is ours, but the order or intent is still active
      "orphan" - it is ours, but the order no longer exists
      "demote" - it is ours, the order and the intent are both finished
    """
    if option.get("bytes", 0) < MIN_BYTES:
        return ("skip", "below the size threshold")
    order_id = order_id_from_option(option["option_name"])
    if order_id is None:
        return ("skip", "not a Stripe order option")
    if order is None:
        return ("orphan", f"order {order_id} no longer exists")
    if order["status"] not in FINISHED_ORDER_STATUSES:
        return ("keep", "order is still active")
    if intent is not None and intent.get("status") not in FINISHED_INTENT_STATUSES:
        return ("keep", "Stripe PaymentIntent is still active")
    return ("demote", "order and PaymentIntent are both finished")


def autoloaded_options(min_bytes):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc-tools/v1/autoloaded-options",
        params={"min_bytes": min_bytes},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return r.json()


def get_order(order_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def demote(option_name):
    requests.post(
        f"{WOO_URL}/wp-json/wc-tools/v1/autoloaded-options/demote",
        json={"option_name": option_name},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    demoted = 0
    total_bytes = 0
    for option in autoloaded_options(MIN_BYTES):
        order_id = order_id_from_option(option["option_name"])
        order = get_order(order_id) if order_id is not None else None
        intent = get_intent(intent_id_of(order)) if order else None
        action, reason = decide(option, order, intent)
        if action in ("skip", "keep"):
            continue
        if action == "orphan":
            log.warning("%s: %s", option["option_name"], reason)
        log.info(
            "%s (%d bytes): %s. %s",
            option["option_name"], option.get("bytes", 0), reason,
            "would demote" if DRY_RUN else "demoting",
        )
        if not DRY_RUN:
            demote(option["option_name"])
        demoted += 1
        total_bytes += option.get("bytes", 0)
    log.info(
        "Done. %d option(s) %s, freeing about %d KB from every page load.",
        demoted, "to demote" if DRY_RUN else "demoted", round(total_bytes / 1024),
    )


if __name__ == "__main__":
    run()
find-stale-autoload.js
/**
 * Find stale, oversized autoloaded wp_options rows left behind by Stripe order
 * processing, and report which ones are safe to demote to autoload='no'.
 *
 * WooCommerce Stripe gateways write small per-order records while a payment is in
 * flight: an idempotency lock, a processing flag, a cached PaymentIntent snapshot.
 * Some of these are saved with autoload left at the default of "yes", so every
 * single page load, including the storefront, pulls them into the alloptions
 * cache. Once the order is finished they serve no purpose, but nothing ever
 * cleans them up, so the autoloaded payload only grows.
 *
 * This reads a custom, read-only endpoint you add to your store
 * (wp-json/wc-tools/v1/autoloaded-options) that lists autoloaded options above a
 * size threshold, matches the Stripe-related ones back to their order through the
 * order id encoded in the option name, checks the order and its Stripe
 * PaymentIntent are both finished, and reports (or repairs) the ones safe to
 * demote. Read only by default.
 *
 * Guide: https://www.allanninal.dev/woocommerce/autoloaded-options-bloat/
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const MIN_BYTES = Number(process.env.MIN_BYTES || 10000);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Matches option names such as _wc_stripe_idempotency_1042 or
// _wc_stripe_intent_1042, where the trailing digits are the WooCommerce order id.
const ORDER_OPTION_RE = /^_wc_stripe_(?:idempotency|intent|lock)_(\d+)$/;

const FINISHED_ORDER_STATUSES = new Set(["processing", "completed", "refunded", "cancelled", "failed"]);
const FINISHED_INTENT_STATUSES = new Set(["succeeded", "canceled"]);

/** Pull the WooCommerce order id out of a Stripe-related option name, or null. */
export function orderIdFromOption(optionName) {
  const match = ORDER_OPTION_RE.exec(optionName);
  return match ? Number(match[1]) : null;
}

/** The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id. */
export function intentIdOf(order) {
  for (const meta of (order && order.meta_data) || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order && order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

/**
 * Pure decision: what to do with one autoloaded option row.
 *
 * option is an object with at least option_name and bytes.
 * order is the matching WooCommerce order object, or null if not found.
 * intent is the matching Stripe PaymentIntent object, or null if not found.
 *
 * Returns [action, reason]. action is one of:
 *   "skip"   - leave it alone, not ours or below the size threshold
 *   "keep"   - it is ours, but the order or intent is still active
 *   "orphan" - it is ours, but the order no longer exists
 *   "demote" - it is ours, the order and the intent are both finished
 */
export function decide(option, order, intent) {
  if ((option.bytes || 0) < MIN_BYTES) return ["skip", "below the size threshold"];
  const orderId = orderIdFromOption(option.option_name);
  if (orderId === null) return ["skip", "not a Stripe order option"];
  if (!order) return ["orphan", `order ${orderId} no longer exists`];
  if (!FINISHED_ORDER_STATUSES.has(order.status)) return ["keep", "order is still active"];
  if (intent && !FINISHED_INTENT_STATUSES.has(intent.status)) {
    return ["keep", "Stripe PaymentIntent is still active"];
  }
  return ["demote", "order and PaymentIntent are both finished"];
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function autoloadedOptions(minBytes) {
  const res = await fetch(
    `${WOO_URL}/wp-json/wc-tools/v1/autoloaded-options?min_bytes=${minBytes}`,
    { headers: { Authorization: AUTH } },
  );
  if (!res.ok) throw new Error(`autoloaded-options returned ${res.status}`);
  return res.json();
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function demoteOption(optionName) {
  const res = await fetch(`${WOO_URL}/wp-json/wc-tools/v1/autoloaded-options/demote`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: AUTH },
    body: JSON.stringify({ option_name: optionName }),
  });
  if (!res.ok) throw new Error(`demote ${optionName} returned ${res.status}`);
}

export async function run() {
  let demoted = 0;
  let totalBytes = 0;
  for (const option of await autoloadedOptions(MIN_BYTES)) {
    const orderId = orderIdFromOption(option.option_name);
    const order = orderId !== null ? await woo(`/orders/${orderId}`) : null;
    const intent = order ? await getIntent(intentIdOf(order)) : null;
    const [action, reason] = decide(option, order, intent);
    if (action === "skip" || action === "keep") continue;
    if (action === "orphan") console.warn(`${option.option_name}: ${reason}`);
    console.log(
      `${option.option_name} (${option.bytes || 0} bytes): ${reason}. ` +
      `${DRY_RUN ? "would demote" : "demoting"}`,
    );
    if (!DRY_RUN) await demoteOption(option.option_name);
    demoted++;
    totalBytes += option.bytes || 0;
  }
  console.log(
    `Done. ${demoted} option(s) ${DRY_RUN ? "to demote" : "demoted"}, ` +
    `freeing about ${Math.round(totalBytes / 1024)} KB from every page load.`,
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether database rows get touched. Because we kept decide pure, the test needs no network, no Stripe account, and no live database. It just feeds in plain objects and checks the action.

test_autoloaded_decide.py
from find_stale_autoload import decide, order_id_from_option, intent_id_of


def option(**over):
    base = {"option_name": "_wc_stripe_idempotency_1042", "bytes": 20000}
    base.update(over)
    return base


def intent(**over):
    base = {"status": "succeeded"}
    base.update(over)
    return base


def test_demote_when_order_and_intent_finished():
    order = {"status": "completed"}
    assert decide(option(), order, intent())[0] == "demote"


def test_skip_when_below_size_threshold():
    order = {"status": "completed"}
    assert decide(option(bytes=500), order, intent())[0] == "skip"


def test_skip_when_option_name_not_ours():
    order = {"status": "completed"}
    result = decide(option(option_name="_transient_unrelated_thing"), order, intent())
    assert result[0] == "skip"


def test_orphan_when_order_missing():
    assert decide(option(), None, None)[0] == "orphan"


def test_keep_when_order_still_active():
    order = {"status": "pending"}
    assert decide(option(), order, intent())[0] == "keep"


def test_keep_when_intent_still_active():
    order = {"status": "processing"}
    assert decide(option(), order, intent(status="requires_action"))[0] == "keep"
find-stale-autoload.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, orderIdFromOption, intentIdOf } from "./find-stale-autoload.js";

const option = (over = {}) => ({ option_name: "_wc_stripe_idempotency_1042", bytes: 20000, ...over });
const intent = (over = {}) => ({ status: "succeeded", ...over });

test("demote when order and intent finished", () => {
  const order = { status: "completed" };
  assert.equal(decide(option(), order, intent())[0], "demote");
});

test("skip when below size threshold", () => {
  const order = { status: "completed" };
  assert.equal(decide(option({ bytes: 500 }), order, intent())[0], "skip");
});

test("orphan when order missing", () => {
  assert.equal(decide(option(), null, null)[0], "orphan");
});

test("keep when order still active", () => {
  const order = { status: "pending" };
  assert.equal(decide(option(), order, intent())[0], "keep");
});

Case studies

Subscription store

Four years of renewal locks nobody removed

A subscriptions store had been running the same Stripe gateway for four years. Every renewal wrote an idempotency row, and none of them were ever cleared. The autoloaded set had grown past six megabytes, and admin pages that used to load in under a second were taking four or five.

Running the job in dry run first showed over eleven thousand old rows tied to renewals from years earlier, all long since completed. After a careful demote pass, dashboard load times were back to normal the same day.

Gateway migration

The switch to Stripe direct that left the old plugin's data behind

A shop moved off WooPayments to Stripe direct, but the old plugin was only deactivated, not uninstalled, so its settings and cached data stayed in the options table with autoload still on. Nobody noticed until a slow admin ticket got escalated.

Because the leftover rows did not match the current gateway's naming pattern, the job correctly skipped them rather than guessing. The fix there was a manual review and a clean uninstall of the old plugin, which is exactly the kind of case the orphan and skip actions are meant to protect against.

What good looks like

After this runs on a schedule, autoloaded bloat stops being an invisible tax on every page. New per-order rows still get created while payments are in flight, exactly as they should, but they stop piling up forever once the order is settled. Keep the job running even after a big cleanup, since new orders never stop happening.

FAQ

Why do autoloaded options slow down my WooCommerce store?

WordPress loads every option with autoload set to yes into memory on every single page load, including the storefront and REST API calls. When plugins like a Stripe gateway leave behind large per-order records with autoload still on, that payload only grows, so every page pays the cost even though most of those rows are never read again.

Is it safe to change an option's autoload value with a script?

Yes, when the script only touches options that match a known pattern, such as a Stripe order lock or idempotency key, and only after confirming both the WooCommerce order and its Stripe PaymentIntent have reached a finished state. Start in dry run mode to review the list before it writes anything.

How often should I run the autoload cleanup job?

Once a week or once a month is enough for most stores, since autoloaded bloat builds up slowly. Run it right after a big sale or a plugin migration too, since those are the moments that tend to leave the most leftover rows behind.

Related field notes

Citations

On the problem:

  1. WordPress developer docs: the autoload parameter and how the alloptions cache is built from it. developer.wordpress.org/reference/functions/wp_load_alloptions
  2. WordPress developer docs: add_option and the effect of the autoload argument. developer.wordpress.org/reference/functions/add_option
  3. Guide: finding and reducing autoloaded data bloat in the wp_options table. kinsta.com/blog/wordpress-autoload

On the solution:

  1. Stripe API: retrieve a PaymentIntent to confirm its current status before acting on it. docs.stripe.com/api/payment_intents/retrieve
  2. WooCommerce REST API: read an order and its meta data, including the saved Stripe intent id. woocommerce.github.io/woocommerce-rest-api-docs
  3. WordPress developer docs: registering a custom REST API route for a read-only diagnostic endpoint. developer.wordpress.org/rest-api/extending-the-rest-api/adding-custom-endpoints

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this speed up your store?

If this saved you a slow dashboard or a scary page speed report, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes