How to measure hook timings with microtime in PHP in WordPress

Contents

Introduction

This article is a comprehensive, practical guide to measuring WordPress hook timings using microtime (and hrtime when available). You will learn why and when to measure hook timings, multiple implementation techniques (simple to advanced), how to measure both actions and filters safely, how to log and display results, and best practices to minimize measurement overhead and avoid breaking behavior. Code examples are provided for direct copy/paste each snippet is placed in the required EnlighterJSRAW pre tag and labeled as PHP for syntax highlighting.

Why measure hook timings?

  • Identify slow callbacks: Hooks are central to WordPress extensibility. Slow plugin/theme callbacks attached to actions/filters are common causes of slow page loads.
  • Prioritize optimization: Timing lets you focus on the callbacks that consume the most wall-clock time.
  • Profile real runtime conditions: Measuring within WP hooks observes real input data, DB calls, HTTP requests, and template execution.

Microtime basics

microtime(true) returns float seconds with microsecond precision. On PHP 7.3 hrtime(true) returns integer nanoseconds with lower jitter and better precision. Use hrtime when available fall back to microtime otherwise. Convert to milliseconds (ms) or seconds for reporting:

  • seconds: microtime(true) difference
  • milliseconds: (microtime(true_end) – microtime(true_start)) 1000
  • nanoseconds (hrtime): diff in ns, convert by /1e6 to ms

Safety and rules

  • Filters must return the original/modified value: Your wrapper must return the underlying callbacks return value exactly (or a modified value intentionally).
  • Preserve accepted args: Keep the accepted_args count do not alter function signatures in ways that WP will mis-handle.
  • Minimal overhead: The measurement code should be tiny and only enabled in debug contexts (WP_DEBUG, admin, or by an option) to avoid runtime overhead in production.
  • Do not interfere with hook removal: If you manipulate wp_filter internals, keep IDs intact so other code that removes callbacks still works.
  • Exception handling: Ensure you record end time even if the wrapped callback throws an exception rethrow if needed.

Short checklist before measuring

  1. Enable a safe output channel: error_log, file under wp-content, or admin-only HTML.
  2. Limit scope: measure a specific hook, specific plugin callbacks, or only in admin—avoid global always-on measurement.
  3. Prefer hrtime when available.
  4. Store aggregated data for later analysis to reduce per-request noise.

Simple timing for a single callback

Use this when you know the callback function you want to measure (function name, static/class method, or closure stored in variable). This wraps the call and logs duration. Works for both actions and filters if its a filter, return the result.


Measure and wrap every callback on a specific hook

This approach iterates the WP_Hook callbacks for a named hook and replaces each callback function with a wrapper closure that measures execution time. The wrapper retains the same unique id key and accepted_args so removal and ordering remain consistent. Use only in debug or admin contexts.

callbacks as priority => callbacks ) {
            foreach ( callbacks as id => cb ) {
                // Preserve original callback and accepted args
                original = cb[function]
                accepted = isset( cb[accepted_args] ) ? (int) cb[accepted_args] : 0

                // Skip if already wrapped (avoid double-wrapping)
                if ( is_string( original )  strpos( original, wp_hook_timing_wrapper_ ) === 0 ) {
                    continue
                }

                wrapper = function() use ( original, hook, priority, id, accepted ) {
                    use_hr = function_exists( hrtime )
                    start = use_hr ? hrtime( true ) : microtime( true )

                    // Get the function args passed by WP
                    args = func_get_args()
                    try {
                        ret = call_user_func_array( original, args )
                    } finally {
                        end = use_hr ? hrtime( true ) : microtime( true )
                        elapsed = use_hr ? ( end - start ) / 1e6 : ( end - start )  1000
                        label = self::callable_label( original )
                        self::timings[] = array(
                            hook     => hook,
                            priority => priority,
                            id       => id,
                            label    => label,
                            ms       => elapsed,
                            args     => count( args ),
                            time     => microtime( true ),
                        )
                    }

                    return isset( ret ) ? ret : null
                }

                // Replace function in callbacks structure with wrapper
                wp_filter[ hook ]->callbacks[ priority ][ id ][function] = wrapper
                wp_filter[ hook ]->callbacks[ priority ][ id ][accepted_args] = accepted
            }
        }
    }

    public static function get_timings() {
        return self::timings
    }

    private static function callable_label( callable ) {
        if ( is_string( callable ) ) {
            return callable
        }
        if ( is_array( callable ) ) {
            if ( is_object( callable[0] ) ) {
                return get_class( callable[0] ) . :: . callable[1]
            }
            return callable[0] . :: . callable[1]
        }
        if ( callable instanceof Closure ) {
            return Closure
        }
        return callable
    }
}

// Example: enable wrapping for the the_content filter
add_action( init, function() {
    WP_Hook_Timing_Wrap::enable_for_hook( the_content )
} )

// Example: show collected timings in admin footer (admins only)
add_action( admin_footer, function() {
    if ( ! current_user_can( manage_options ) ) {
        return
    }
    timings = WP_Hook_Timing_Wrap::get_timings()
    if ( empty( timings ) ) {
        return
    }
    error_log( [HOOK-TIMER] Collected timings:  . print_r( timings, true ) )
})
?>

Notes about this approach

  • It manipulates the global wp_filter structure therefore it must be used carefully and preferably in debug environments.
  • Wrapper closures capture the original callable. The unique id (the array key) remains unchanged so future remove_filter calls that use the same id should still match.
  • Some callbacks are objects with protected/private invocation behavior in most cases call_user_func_array works fine. For specialized cases (invokable objects with magic behavior), test carefully.

Measuring filter callbacks without modifying wp_filter (remove->wrap->re-add)

If you want to avoid touching internals, remove the known callback and re-add a wrapper at the same priority. This requires you have the original callback reference to remove it first.


Aggregating and storing timings

Per-request logs are noisy. Aggregate in a persistent sink for analysis:

  • Log to a file under wp-content (watch permissions).
  • Use error_log/WP_DEBUG_LOG for quick debugging.
  • Aggregate in a transient for short windows and then export/visualize.
  • Use a custom table for long-term analysis and percentile computations.

Use hrtime when available (higher resolution)

hrtime(true) returns integer nanoseconds on PHP 7.3 . It is monotonic and less affected by system clock changes. Use it preferentially for more precise timing, especially for very short durations.

 integer)
    return (int) ( microtime( true )  1e9 )
}

function ns_to_ms( ns ) {
    return ns / 1e6
}
?>

Measure memory usage too

Sometimes a slow hook is also memory-heavy. Capture memory_get_usage() and memory_get_peak_usage() along with timing.


Measuring AJAX, REST, CRON, and CLI runs

Hook timings apply equally to admin-ajax, REST endpoints, WP-Cron runs, and WP-CLI usage. Ensure your logging sink is writeable and accessible in the execution context (WP-CLI may run as a different user). Consider tagging logs with request type, current URL or route, and user ID.

Presenting results (admin UI example)

Its useful to display an HTML table with timing results to admins. Heres a minimal admin footer renderer that prints a table using the collected timings array from our wrapper class. The output is displayed only to administrators.

 0, total => 0, max => 0 )
        }
        agg[ label ][count]  
        agg[ label ][total]  = t[ms]
        agg[ label ][max] = max( agg[ label ][max], t[ms] )
    }

    // Simple HTML table output (admin only). We use echo for admin bar adjust as needed.
    echo 
echo Hook timings (sample) echo foreach ( agg as label => data ) { avg = data[total] / data[count] echo } echo
CallbackCallsTotal msAvg msMax ms
. esc_html( label ) . . intval( data[count] ) . . number_format( data[total], 3 ) . . number_format( avg, 3 ) . . number_format( data[max], 3 ) .
}, 100 ) ?>

Measuring nested hooks and attribution

Hooks often cause other hooks. To attribute time correctly, you must decide whether timings are:

  • Inclusive: include time for nested hooks (the simple wrapper approach yields inclusive times for each callback).
  • Exclusive: subtract time spent in nested measured callbacks to get exclusive time for a particular callable. Implementing exclusive timing requires stack bookkeeping: push current start, on nested start pause parent, on nested end resume parent, and attribute durations accordingly.
 label, start => now, child => 0 )
        self::stack[] = frame
    }

    public static function stop() {
        now = microtime( true )
        frame = array_pop( self::stack )
        duration = ( now - frame[start] )  1000
        exclusive = duration - frame[child]
        if ( ! isset( self::results[ frame[label] ] ) ) {
            self::results[ frame[label] ] = array( count => 0, exclusive_total => 0 )
        }
        self::results[ frame[label] ][count]  
        self::results[ frame[label] ][exclusive_total]  = exclusive

        // If theres a parent frame, accumulate childs inclusive duration into parents child sum
        if ( ! empty( self::stack ) ) {
            parent_index = count( self::stack ) - 1
            self::stack[ parent_index ][child]  = duration
        }
    }

    public static function results() {
        return self::results
    }
}
?>

Comparison to profiler tools

Microtime-based hook timing is lightweight and purpose-focused, but if you need function-level call graphs, CPU samples, or full flamegraphs, consider:

These tools have lower measurement overhead for deep profiling and provide richer analysis, but they require server configuration and are not always available on shared hosts.

Best practices summary

  1. Enable only in debug or admin contexts never leave heavy measurement enabled in production without clear purpose.
  2. Use hrtime when available for higher resolution. Fallback to microtime(true) otherwise.
  3. Keep wrappers minimal avoid heavy logging on every request — aggregate then persist.
  4. Preserve return values for filters and accepted_args counts.
  5. Use unique labels and capture context: hook name, priority, plugin origin (if possible), request type, and user id.
  6. Consider exclusive timing if nested hooks produce confusing inclusive counts.
  7. Compare results across multiple requests and different pages — single-request noise is common.

Troubleshooting and pitfalls

  • Closures in wp_filter can be hard to remove via remove_filter if you need to target them later, record the unique id and keep a reference.
  • Filesystem permissions can prevent writing to logs under wp-content ensure proper ownership and mode.
  • Timing itself adds overhead. Time a wrapper-only request (no wrapped callbacks) to estimate baseline overhead, then subtract or account for it in your analysis.
  • Some plugins modify wp_filter at late priority or dynamically add callbacks the time you enable wrapping matters (use an early hook like init if you need to wrap later-added callbacks, or wrap on plugins_loaded).

Final checklist before deploying timer code

  1. Scope: target specific hooks or admin only.
  2. Persistence: decide log file, transient or DB storage.
  3. Format: milliseconds with 3 decimal places or ns for high precision.
  4. Visibility: admin UI, error_log, or external analytics endpoint.
  5. Cleanup: ensure debug-only wrappers can be removed easily or gated behind a safe conditional flag.

References and further reading

Complete example: small production-safe timing plugin skeleton

This final snippet is a tiny plugin skeleton that wraps specific hooks, logs per-request aggregated results to wp-content/hook-timings.log and shows admin-only data. It guards by WP_DEBUG and a constant HOOK_TIMER_ENABLE to allow explicit enabling without leaving it in production unintentionally.

callbacks as priority => callbacks ) {
            foreach ( callbacks as id => cb ) {
                original = cb[function]
                accepted = isset( cb[accepted_args] ) ? (int) cb[accepted_args] : 0

                // Avoid wrapping our own wrappers
                if ( is_string( original )  strpos( original, hook_timer_wrapper_ ) === 0 ) {
                    continue
                }

                wrapper = function() use ( original, hook, priority, id ) {
                    start = self::use_hr ? hrtime( true ) : microtime( true )
                    args = func_get_args()
                    try {
                        ret = call_user_func_array( original, args )
                    } finally {
                        end = self::use_hr ? hrtime( true ) : microtime( true )
                        elapsed = self::use_hr ? ( end - start ) / 1e6 : ( end - start )  1000
                        self::records[] = array(
                            hook     => hook,
                            priority => priority,
                            label    => self::callable_label( original ),
                            ms       => elapsed,
                        )
                    }
                    return isset( ret ) ? ret : null
                }

                wp_filter[ hook ]->callbacks[ priority ][ id ][function] = wrapper
                wp_filter[ hook ]->callbacks[ priority ][ id ][accepted_args] = accepted
            }
        }
    }

    public static function callable_label( callable ) {
        if ( is_string( callable ) ) {
            return callable
        }
        if ( is_array( callable ) ) {
            if ( is_object( callable[0] ) ) {
                return get_class( callable[0] ) . :: . callable[1]
            }
            return callable[0] . :: . callable[1]
        }
        if ( callable instanceof Closure ) {
            return Closure
        }
        return callable
    }

    public static function flush_log() {
        if ( empty( self::records ) ) {
            return
        }
        file = WP_CONTENT_DIR . /hook-timings.log
        ts = date( c )
        msg = === Hook Timings at {ts} ===n
        // Aggregate by label
        agg = array()
        foreach ( self::records as r ) {
            lbl = r[label]
            if ( ! isset( agg[ lbl ] ) ) {
                agg[ lbl ] = array( count => 0, total => 0, max => 0 )
            }
            agg[ lbl ][count]  
            agg[ lbl ][total]  = r[ms]
            agg[ lbl ][max] = max( agg[ lbl ][max], r[ms] )
        }
        foreach ( agg as label => d ) {
            avg = d[total] / d[count]
            msg .= sprintf( %s  calls: %d  total: %.3f ms  avg: %.3f ms  max: %.3f msn, label, d[count], d[total], avg, d[max] )
        }
        @file_put_contents( file, msg . n, FILE_APPEND  LOCK_EX )
    }
}

Hook_Timer_Debug::init()
?>

This skeleton demonstrates guarded, scoped, and aggregated timing with minimal runtime overhead and an easy way to turn it off.

Closing notes

Measuring hook timings with microtime (or hrtime) in PHP is a practical way to detect, attribute, and prioritize performance issues within WordPress. Start small, focus on a few suspect hooks, aggregate results, and iterate. For deep or production-grade profiling, pair this approach with professional profilers like Xdebug, Tideways, or Blackfire.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *