WP Webhooks / Blog / WordPress internals
Article · WordPress internals

Action Scheduler: Prevent Duplicate Scheduled Actions

Stop Action Scheduler queuing duplicate jobs with the $unique parameter and as_has_scheduled_action() — exact signatures, behaviour, and gotchas.

7 min 2026-06-22
#action-scheduler#wordpress#php

TL;DR: Action Scheduler gives you two built-in ways to stop duplicate jobs — the $unique flag on the scheduling functions, and a guard with as_has_scheduled_action().

  • $unique = true skips the insert if a matching action is already pending or in-progress, returning 0 instead of an action ID.
  • as_has_scheduled_action( $hook, $args, $group ) (since 3.3.0) returns true when a matching action already exists — match is exact on hook, args, and group.
  • Neither is fully race-proof. $unique collapses the check and the insert into one call, so prefer it for events that can fire twice.

/ Two approaches

How do you prevent duplicate scheduled actions in Action Scheduler?

There are two supported approaches, and both compare actions on the same triple: hook name, arguments array, and group. The first is the $unique parameter on the scheduling functions — set it to true and Action Scheduler refuses to create a second action that matches one already queued. The second is as_has_scheduled_action(), which lets you check whether a matching action exists and branch on the result before you schedule.

Use $unique when a single user event can fire the same enqueue twice. Use as_has_scheduled_action() when you set up a recurring action once and want to avoid stacking it on every plugin upgrade. The full signatures live in the Action Scheduler API reference.

/ The flag

What does the $unique parameter do?

$unique is a boolean argument on as_enqueue_async_action(), as_schedule_single_action(), and as_schedule_recurring_action(). When it is true, Action Scheduler will not create the action if one with the same hook, args, and group is already pending or in-progress — the call returns 0 rather than a new action ID. Because the uniqueness check happens inside the same call that performs the insert, it is the tightest guard the API offers.

Note the argument order: $unique sits after $group and before $priority. If you only ever passed a hook and args before, you have to fill the $group slot (even with an empty string) to reach it.

PHP — signatures and a unique enqueue

// Signatures (Action Scheduler core)
as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false, $priority = 10 );
as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false, $priority = 10 );

// At most one sync per order can ever be pending:
as_schedule_single_action(
    time() + 300,
    'sync_order',
    array( $order_id ),
    'orders',
    true   // $unique
);

/ The check

How does as_has_scheduled_action() check for duplicates?

It returns true if at least one action with the given hook — and optionally the given args and group — is currently pending or in-progress. It was added in Action Scheduler 3.3.0 and is the recommended way to guard recurring schedulers you register during activation or upgrade routines, so a plugin update never stacks a second copy of the same five-minute job.

Matching is exact. The args you pass must equal the args you scheduled with, element for element. Omit the args entirely to match any action on that hook. The Action Scheduler usage guide shows the canonical guard pattern.

PHP — guard a recurring action on activation/upgrade

// Run on init / activation — never stack the recurring action.
if ( ! as_has_scheduled_action( 'my_cron_5min' ) ) {
    as_schedule_recurring_action(
        time(),
        5 * MINUTE_IN_SECONDS,
        'my_cron_5min'
    );
}
How Action Scheduler decides whether to insert a duplicate actionA schedule request carrying a hook, args, and group is checked with the unique flag or an as_has_scheduled_action guard. If a matching action already exists the insert is skipped and zero is returned; otherwise a new action is created and its id is returned.

match exists

no match

schedule request
hook + args + group

unique = true
or as_has_scheduled_action guard?

skip insert
returns 0

insert new action
returns action id

FIG 01 — Deduplication decision path

/ Which one

$unique vs as_has_scheduled_action — which should you use?

Use $unique for one-off enqueues fired from request handlers — order placed, form submitted, webhook received — where the same event can realistically fire twice in quick succession. Use as_has_scheduled_action() for recurring actions you register once in activation or upgrade code.

The reason is the race window. as_has_scheduled_action() is a check-then-act: another process can insert a matching action in the gap between your check returning false and your schedule call running. $unique has no gap — the check and the insert are one operation, so two concurrent requests cannot both win.

ScenarioRaw dedup in your codeWebhook Actions
Same event fires twiceTwo actions unless you set $unique or guard manuallyIdempotent dispatch — one delivery per event
Recurring setup re-runs on upgradeGuard every time with as_has_scheduled_actionTrigger bound once in the admin UI
Race between check and insertPossible with check-then-schedule patternsA single queued row per event

/ Edge cases

Why do duplicates still slip through?

Four reasons cover almost every case. First, args mismatch: a single differing argument — an integer where you later pass a string, an extra element, a different order — makes the triple look unique, so normalize args before scheduling and checking. Second, group mismatch: checking without the group you scheduled with. Third, a genuine race when two concurrent requests both pass as_has_scheduled_action() before either inserts — the case $unique exists to close.

Fourth, and the one that surprises people: completed actions do not count. as_has_scheduled_action() only sees pending and in-progress actions, so a single action that already ran will not block you from scheduling it again. That is usually the behaviour you want, but it means it is not a historical "has this ever run" check.

Action Scheduler's idea of "the same action" is an exact match on hook, args, and group. Change one byte of the args array and it is a brand-new action. — Action Scheduler API

The Webhook Actions plugin binds a trigger to a webhook once in the admin UI and dispatches one delivery per event — so you get idempotent outbound webhooks without writing $unique guards by hand. See the Action Scheduler PHP API reference for the rest of the scheduling surface.

/Footnotes
¹ Function signatures: actionscheduler.org/api.
² Recurring-action guard pattern and the 3.3.0 note for as_has_scheduled_action(): actionscheduler.org/usage.
FAQ

Common questions always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

How do I stop Action Scheduler from scheduling duplicate actions? +
Use one of two built-in tools. Pass $unique = true to as_enqueue_async_action(), as_schedule_single_action(), or as_schedule_recurring_action() so a matching action is not created twice, or guard the schedule call with if ( ! as_has_scheduled_action(...) ). Both match on hook, args, and group.
What is the $unique parameter in as_schedule_single_action? +
It is a boolean that, when true, tells Action Scheduler not to create the action if one with the same hook, args, and group is already pending or in-progress. The call returns 0 instead of an action ID. Because the check and insert happen in one call, it is race-safe.
What does as_has_scheduled_action() return? +
It returns true if at least one action matching the given hook (and optional args and group) is currently pending or in-progress, and false otherwise. It was added in Action Scheduler 3.3.0 and is the recommended guard for recurring actions registered during activation or upgrade.
Does as_has_scheduled_action check completed actions? +
No. It only considers pending and in-progress actions. A single action that already ran to completion will not block you from scheduling it again, so it is not a historical "has this ever run" check — it answers "is one queued right now".
Why is as_has_scheduled_action still creating duplicates? +
Usually an args or group mismatch — matching is exact, so a different type, an extra element, or a different order makes the action look unique. It can also be a genuine race: two concurrent requests both pass the check before either inserts. Use the $unique flag to close that race.
Ready

Stop losing webhooks.
Start logging them.

$ wp plugin install flowsystems-webhook-actions --activate