Skip to content

Instantly share code, notes, and snippets.

@simplistik
Last active February 12, 2026 02:33
Show Gist options
  • Select an option

  • Save simplistik/796e46a6e4d2c4d5636c26378b998360 to your computer and use it in GitHub Desktop.

Select an option

Save simplistik/796e46a6e4d2c4d5636c26378b998360 to your computer and use it in GitHub Desktop.
WordPress Object Caching & Query Reduction Guide

WordPress Object Caching & Query Reduction Guide

A practical guide for reducing database queries on WordPress sites where page caching is bypassed (logged-in users, personalized content, membership sites). This guide covers three strategies: ACF (Advanced Custom Fields) options batch caching, fragment caching, and WP_Query optimization.


Table of Contents

  1. When You Need This
  2. Prerequisites
  3. Strategy 1: ACF Options Batch Cache
  4. Strategy 2: Fragment Caching
  5. Strategy 3: WP_Query Optimization
  6. Cache Infrastructure
  7. Safety Rules
  8. Invalidation Patterns
  9. Verification & Debugging
  10. Common Pitfalls

When You Need This

Page caching (Varnish, nginx, WP Engine's Evercache, etc.) is bypassed in these scenarios:

  • Logged-in users — Most managed hosts skip page cache entirely for authenticated requests
  • Membership/paywall sites — MemberPress, WooCommerce Memberships, Restrict Content Pro
  • WooCommerce — Cart, checkout, and account pages are never page-cached
  • Personalized content — Any page that varies by user

When page cache is bypassed, every request runs full PHP + MySQL. A typical WordPress theme with ACF can generate 100-200+ DB queries per page load. The strategies below can cut that by 50-80%.

How to measure the problem

Install Query Monitor and load a page as a logged-in user. Look at:

  • Queries by Component — Identify which theme files generate the most queries
  • Slow Queries — Anything over 0.05s
  • Duplicate Queries — Same query running multiple times

Prerequisites

Persistent Object Cache

These strategies require a persistent object cache backend. Without one, wp_cache_get/wp_cache_set only last for the current request (useless).

Host Backend How to Enable
WP Engine Memcached or Redis Enabled by default via object-cache.php drop-in
Pantheon Redis Enabled by default
Kinsta Redis Enabled by default
Cloudways Redis/Memcached Enable via dashboard
Generic VPS Redis Install Redis Object Cache plugin
Local dev None Use database fallback (see Cache Infrastructure)

Check if persistent object cache is active:

// Returns true if a persistent backend is available
wp_using_ext_object_cache();

ACF Pro (for Strategy 1)

Strategy 1 specifically targets ACF options fields. If you're not using ACF, skip to Strategy 2.


Strategy 1: ACF Options Batch Cache

The Problem

Every get_field('field_name', 'options') call triggers multiple DB queries:

  1. Field key lookupSELECT * FROM wp_options WHERE option_name = '_field_name' (get the field key)
  2. Raw value readSELECT * FROM wp_options WHERE option_name = 'field_name' (get the stored value)
  3. Field config lookupSELECT * FROM wp_posts WHERE post_name = 'field_abc123' (get field type/settings)
  4. Format/hydration — For complex fields (image, relationship, post object), ACF runs additional queries:
    • Image fields: get_post() + wp_get_attachment_metadata() (2-4 more queries)
    • Relationship fields: get_post() per related post (N more queries)
    • Post object fields: get_post() per post (N more queries)

A theme with 48 get_field('...', 'options') calls can easily generate 60-80+ queries just from options fields.

The Solution

Intercept ACF's loading pipeline with two filters to cache formatted (final) values in a single object cache blob. This skips all DB lookups AND expensive formatting on subsequent requests.

Implementation

Hook Registration

// Frontend only — never interfere with admin
if ( ! is_admin() ):
  add_filter( 'acf/pre_load_value', [$this, 'acf_options_pre_load'], 10, 3 );
  add_filter( 'acf/format_value',   [$this, 'acf_options_format_value'], 999, 3 );
  add_action( 'shutdown',           [$this, 'acf_options_persist_cache'] );
endif;

// Invalidation — must run in admin too
add_action( 'acf/save_post', [$this, 'acf_options_invalidate'], 20 );

Filter 1: acf/pre_load_value — Skip the DB

This filter fires before ACF reads from the database. Returning a non-null value tells ACF "I already have the raw value, skip the DB."

public function acf_options_pre_load( $check, $post_id, $field )
{
  // Only intercept options page fields
  if ( $post_id !== 'options' && $post_id !== 'option' ) return $check;

  $this->ensure_options_cache_loaded();

  $field_name = $field['name'] ?? null;
  if ( ! $field_name ) return $check;

  // If we have a cached formatted value, return a sentinel
  // The actual value is injected by the format_value filter
  if ( array_key_exists( $field_name, $this->acf_options_formatted ) )
    return '__acf_cached_sentinel__';

  return $check;
}

Filter 2: acf/format_value — Return or Capture

This filter fires after ACF formats the raw value into its final form. Use priority 999 to run last.

public function acf_options_format_value( $value, $post_id, $field )
{
  if ( $post_id !== 'options' && $post_id !== 'option' ) return $value;

  $this->ensure_options_cache_loaded();

  $field_name = $field['name'] ?? null;
  if ( ! $field_name ) return $value;

  // Cache HIT — return stored formatted value
  if ( array_key_exists( $field_name, $this->acf_options_formatted ) )
    return $this->acf_options_formatted[ $field_name ];

  // Cache MISS — capture the just-formatted value
  $this->acf_options_formatted[ $field_name ] = $value;
  $this->acf_options_dirty = true;

  return $value;
}

Persistence: shutdown Action

Write the entire blob to object cache once at the end of the request, only if new values were captured.

public function acf_options_persist_cache()
{
  if ( ! $this->acf_options_dirty ) return;

  wp_cache_set( 'acf_options_v1', $this->acf_options_formatted, 'your_cache_group', 0 );
}

Using 0 for TTL (Time To Live) means the cache lives until explicitly invalidated. This is safe because options rarely change and we invalidate on save.

Lazy Loading

private function ensure_options_cache_loaded()
{
  if ( $this->acf_options_loaded ) return;

  $cached = wp_cache_get( 'acf_options_v1', 'your_cache_group' );

  $this->acf_options_formatted = is_array( $cached ) ? $cached : [];
  $this->acf_options_loaded    = true;
}

Invalidation

public function acf_options_invalidate( $post_id )
{
  if ( $post_id !== 'options' && $post_id !== 'option' ) return;

  wp_cache_delete( 'acf_options_v1', 'your_cache_group' );

  // Also invalidate any fragment caches that depend on options data
  // e.g., trending news bar, site-wide banners, etc.
  wp_cache_delete( 'your_fragment_key', 'your_cache_group' );

  // Reset in-memory state
  $this->acf_options_formatted = [];
  $this->acf_options_loaded    = true;
  $this->acf_options_dirty     = false;
}

Class Properties

private $acf_options_formatted = null;
private $acf_options_loaded    = false;
private $acf_options_dirty     = false;

Why Cache Formatted Values (Not Raw)

ACF's formatting step is where the expensive queries happen. A raw image field value is just a post ID (integer). The formatted value includes the full attachment array with URLs, dimensions, alt text — which requires get_post() + wp_get_attachment_metadata(). Caching the formatted value skips all of that.

is_admin() Guard Is Critical

Never run these filters in wp-admin. The ACF field editor and options pages must always read/write real values. The is_admin() check ensures:

  • Admin always sees current values
  • Field saves work correctly
  • No stale data in the editor

Strategy 2: Fragment Caching

The Problem

Template partials that render lists of posts (related articles, trending bars, news query blocks) run WP_Query + multiple get_the_title(), get_permalink(), get_the_post_thumbnail_url() calls. Each of these hits the database.

The Solution

Cache the rendered HTML output of expensive template partials. On cache hit, echo the HTML and return immediately — zero DB queries.

Implementation Pattern

<?php
  // 1. Guard: only use object cache on hosts that support it
  if ( wp_using_ext_object_cache() ):
    $cache_key = 'your_prefix_' . get_the_ID(); // or a static key for global fragments
    $cached    = wp_cache_get( $cache_key, 'your_cache_group' );

    if ( $cached !== false ):
      echo $cached;
      return;
    endif;
  endif;

  // 2. Start output buffering
  ob_start();
?>

<!-- Your expensive template HTML here -->
<div class="related-posts">
  <?php
    $q = new WP_Query([...]);
    while ( $q->have_posts() ) : $q->the_post();
      // ... render post cards
    endwhile;
    wp_reset_postdata();
  ?>
</div>

<?php
  // 3. Capture and output
  $output = ob_get_clean();
  echo $output;

  // 4. Store in cache
  if ( wp_using_ext_object_cache() )
    wp_cache_set( $cache_key, $output, 'your_cache_group', HOUR_IN_SECONDS );

Cache Key Strategies

Fragment Type Cache Key Pattern Example
Per-post fragment prefix_{post_id} related_123, header_456
Global fragment Static string trending_html, footer_nav
Per-block instance prefix_{md5(block_id)} nq_a1b2c3d4
Per-category prefix_{term_id} cat_posts_42
Per-user prefix_{user_id} profile_widget_7

Block-Based Cache Keys

For ACF blocks or Gutenberg blocks that can appear multiple times on a page, use the block instance ID:

$block_id  = ! empty( $block['id'] ) ? $block['id'] : 'fallback_' . get_the_ID();
$cache_key = 'nq_' . md5( $block_id );

The md5() ensures a clean, fixed-length key regardless of block ID format.

TTL Guidelines

Fragment Type Recommended TTL Rationale
Post metadata (header, thumbnail, profile) 1 hour Changes infrequently, invalidated on save_post
Related/category posts 1 hour New posts appear within TTL window
Global site elements (trending, banners) 30 minutes Safety net; also event-invalidated
News query blocks 30 minutes Content editors expect near-realtime updates
Navigation menus 0 (no TTL) Invalidate on wp_update_nav_menu

Admin Guard for Block Partials

If a template partial renders in both the block editor (admin) and the frontend, add an is_admin() guard:

if ( wp_using_ext_object_cache() && ! is_admin() ):
  // cache logic...
endif;

// ... template code ...

if ( wp_using_ext_object_cache() && ! is_admin() )
  wp_cache_set( $cache_key, $output, 'your_cache_group', TTL );

This prevents caching admin-specific markup (preview classes, edit buttons) and serving it to frontend users.


Strategy 3: WP_Query Optimization

no_found_rows

By default, WP_Query adds SQL_CALC_FOUND_ROWS to every query and runs a follow-up SELECT FOUND_ROWS() query. This calculates the total number of matching rows for pagination.

If your query doesn't need pagination (fixed number of results, no "next page" link), disable it:

$args = [
  'posts_per_page' => 4,
  'no_found_rows'  => true,  // Skip SQL_CALC_FOUND_ROWS
  // ...
];
$q = new WP_Query( $args );

This eliminates one query per WP_Query call and makes the primary query faster (MySQL doesn't need to calculate the full result count).

When to use no_found_rows:

  • Related posts sections
  • "Latest posts" widgets
  • Any fixed-count query without pagination controls

When NOT to use it:

  • Archive pages with pagination
  • Search results
  • Any template that calls get_next_posts_link(), paginate_links(), or $q->max_num_pages

Other WP_Query Optimizations

$args = [
  'posts_per_page'         => 4,
  'no_found_rows'          => true,
  'update_post_meta_cache' => false,  // Skip postmeta cache priming if you don't need meta
  'update_post_term_cache' => false,  // Skip term cache priming if you don't need categories/tags
  'fields'                 => 'ids',  // Return only IDs if you don't need full post objects
];

Use these judiciously — only disable meta/term cache priming if you truly don't access that data in the loop. If you call get_the_category() or get_post_meta() in the loop, keep cache priming enabled (it's more efficient to prime once than query per-post).


Cache Infrastructure

Dual-Backend Pattern (Production + Local Dev)

On production with a persistent object cache (Redis/Memcached), use wp_cache_* directly. On local dev without persistent cache, fall back to the wp_options table with manual expiration.

const CACHE_GROUP = 'your_prefix';

protected function get_db_cache( $key )
{
  if ( wp_using_ext_object_cache() ):
    $data = wp_cache_get( $key, self::CACHE_GROUP );
    return $data !== false ? $data : null;
  endif;

  // Database fallback for local dev
  $data = get_option( $key, null );

  if ( $data === null || ! is_array( $data ) || ! array_key_exists( '_value', $data ) )
    return null;

  if ( isset( $data['_expires'] ) && $data['_expires'] > 0 && time() > $data['_expires'] ):
    delete_option( $key );
    return null;
  endif;

  return $data['_value'];
}

protected function set_db_cache( $key, $value, $expiration = 0 )
{
  if ( wp_using_ext_object_cache() )
    return wp_cache_set( $key, $value, self::CACHE_GROUP, $expiration );

  $data = [
    '_value'   => $value,
    '_expires' => $expiration > 0 ? time() + $expiration : 0,
  ];

  return update_option( $key, $data, false ); // false = don't autoload
}

protected function delete_db_cache( $key )
{
  if ( wp_using_ext_object_cache() )
    return wp_cache_delete( $key, self::CACHE_GROUP );

  return delete_option( $key );
}

Why Not Just Use Transients?

This is a fair question. WordPress transients (get_transient/set_transient) already have a dual-backend design — they use the object cache when available and fall back to the wp_options table when it's not. On paper, that's exactly what our get_db_cache/set_db_cache does.

The problem is what happens when the object cache clears on production.

On managed hosts like WP Engine, the object cache (Memcached/Redis) gets flushed on every deploy, manual cache clear, or infrastructure event. When a persistent object cache is active, WordPress stores transients only in the object cache — it skips the database entirely. So when the cache flushes, your transients are gone. There is no DB fallback to recover from.

This means:

  1. Data loss on cache clear — After a deploy or flush, the first visitor triggers a full regeneration of every transient. For expensive operations (external API calls, heavy queries), this creates a spike of slow requests.
  2. No cache group support — Transients are stored as flat wp_options rows with no grouping mechanism. You can't bulk-invalidate "all caches for this feature" without knowing every key name.
  3. Autoload bloat — Non-expiring transients default to autoload = yes, meaning they load into memory on every single request even if that request never reads them.

The get_db_cache/set_db_cache pattern solves this by giving you explicit control: wp_cache_* with a cache group on production, wp_options with manual TTL on local dev. You choose the backend per environment rather than trusting WordPress to pick the right one.

Cache Group Constant

Define your cache group once as a class constant:

class Client
{
  const CACHE_GROUP = 'your_prefix';
}

Reference it everywhere: Client::CACHE_GROUP. This avoids hardcoded strings scattered across template files and makes it easy to change.


Safety Rules

Before caching any template fragment, audit the output for these risks:

1. User-Specific Content

Never cache output that varies by user. This includes:

  • Logged-in/logged-out conditional content
  • Username, avatar, or account links for the viewing user
  • Subscription tier or membership status
  • Cart contents, wishlists, or purchase history
  • MemberPress [mepr-*] shortcodes
  • current_user_can() checks
  • Nonce fields (wp_nonce_field())
  • CSRF (Cross-Site Request Forgery) tokens

Safe to cache: Author profiles, bylines, and avatars for the post author (this is public data about the author, not the viewer).

2. Admin-Only Markup

Fragments that render in both admin and frontend may include:

  • Edit buttons or admin bar links
  • Block editor preview classes (block-admin, is-selected)
  • ACF field preview wrappers

Solution: Guard cache reads/writes with ! is_admin().

3. Dynamic Content Inside Cached Regions

If a cached fragment contains a shortcode or filter that produces dynamic output:

  • the_content() — Runs the_content filter chain. If ads, paywalls, or user-specific shortcodes are injected here, don't cache it.
  • do_shortcode() — Could expand user-specific shortcodes.
  • apply_filters('widget_text', ...) — Could include dynamic widgets.

Solution: Cache around the dynamic parts, not through them. Or ensure the dynamic content is injected via JavaScript (client-side).

4. Forms and CSRF

Never cache HTML containing:

  • wp_nonce_field() output (nonces are user- and time-specific)
  • Login forms
  • Comment forms (unless the form is loaded separately)
  • WooCommerce add-to-cart forms with nonces

Audit Checklist

Before adding fragment caching to a template partial, verify:

  • Output is identical for all users (logged-in, logged-out, admin, subscriber)
  • No current_user_can() or role-based conditionals
  • No nonce fields or CSRF tokens
  • No shortcodes that expand to user-specific content
  • No the_content() call (unless you've verified the filter chain is safe)
  • No is_admin() conditional HTML (or you've added an admin guard)
  • Cache key uniquely identifies the content variant (per-post, per-block, etc.)
  • TTL is appropriate for the content's change frequency
  • Invalidation hook exists for manual/editorial changes

Invalidation Patterns

Event-Based Invalidation (Preferred)

Delete the cache when the underlying data changes. This gives instant updates.

// When a post is saved/updated
add_action( 'save_post', function( $post_id, $post ) {
  if ( wp_is_post_revision( $post_id ) ) return;
  if ( $post->post_type !== 'post' ) return;

  wp_cache_delete( 'related_' . $post_id, 'your_cache_group' );
  wp_cache_delete( 'header_' . $post_id, 'your_cache_group' );
  wp_cache_delete( 'thumb_' . $post_id, 'your_cache_group' );
  wp_cache_delete( 'profile_' . $post_id, 'your_cache_group' );
}, 10, 2 );

// When ACF options page is saved
add_action( 'acf/save_post', function( $post_id ) {
  if ( $post_id !== 'options' && $post_id !== 'option' ) return;

  wp_cache_delete( 'acf_options_v1', 'your_cache_group' );
  wp_cache_delete( 'trending_html', 'your_cache_group' );
  // Delete any other fragments that depend on options data
}, 20 );

// When a nav menu is updated
add_action( 'wp_update_nav_menu', function() {
  wp_cache_delete( 'nav_primary', 'your_cache_group' );
});

TTL-Based Invalidation (Safety Net)

Always set a TTL even on event-invalidated caches. This protects against:

  • Missed invalidation hooks
  • Edge cases where save_post doesn't fire (bulk imports, direct DB writes, REST API)
  • Object cache corruption
wp_cache_set( $key, $output, 'your_cache_group', HOUR_IN_SECONDS );

Cascading Invalidation

When one cache depends on another, invalidate both:

// ACF options invalidation also clears trending fragment
// because trending reads from ACF options
public function acf_options_invalidate( $post_id )
{
  if ( $post_id !== 'options' && $post_id !== 'option' ) return;

  wp_cache_delete( 'acf_options_v1', 'your_cache_group' );
  wp_cache_delete( 'trending_html', 'your_cache_group' ); // depends on options
}

Verification & Debugging

Query Monitor

Query Monitor is the essential tool. Key panels:

  • Queries > Queries by Component — Shows query count per theme file
  • Queries > Duplicate Queries — Identical queries running multiple times
  • Queries > Slow Queries — Queries over the threshold
  • Cache > Object Cache — Hit/miss ratio for wp_cache_get calls

Isolating QM on Production

To avoid QM overhead for all users on a live site, set an authentication cookie. Only users with this cookie will see QM output:

// Add to wp-config.php or a mu-plugin
define( 'QM_COOKIE', 'your_secret_qm_cookie' );

Then set the cookie in your browser:

  1. Open browser dev tools > Application/Storage > Cookies
  2. Add cookie: Name = your_secret_qm_cookie, Value = 1, Path = /
  3. Reload the page — QM will appear only for you

Before/After Comparison

  1. Install QM on staging
  2. Load a representative page as a logged-in user — note query count
  3. Deploy caching changes
  4. Load the same page twice (first load primes cache, second load uses cache)
  5. Compare query counts

Expected results:

  • ACF options cache: -60 to -80 queries (depending on number of options fields)
  • Fragment caches: -5 to -15 queries per cached fragment (on cache hit, zero queries from that partial)
  • no_found_rows: -1 query per WP_Query call (also speeds up the main query)

Verifying Cache Invalidation

  1. ACF options: Edit an options page field in wp-admin → verify frontend shows the new value immediately
  2. Post fragments: Edit and save a post → verify the post's cached fragments update
  3. Trending/global: Update the trending articles in ACF → verify the trending bar updates

Checking Object Cache Status

// In a mu-plugin or debug context
add_action( 'wp_footer', function() {
  if ( ! current_user_can( 'manage_options' ) ) return;

  global $wp_object_cache;
  echo '<!-- Object cache hits: ' . $wp_object_cache->cache_hits . ' -->';
  echo '<!-- Object cache misses: ' . $wp_object_cache->cache_misses . ' -->';
});

Common Pitfalls

1. Caching false or Empty Strings

wp_cache_get returns false on cache miss. If your cached value is legitimately false or an empty string, you can't distinguish a miss from a hit.

Solution: Wrap values or use wp_cache_get with a $found parameter:

$found  = false;
$cached = wp_cache_get( $key, $group, false, $found );

if ( $found ):
  echo $cached; // Could be empty string — that's valid
  return;
endif;

2. Forgetting wp_reset_postdata()

If you cache the output of a template that calls the_post() inside a secondary query loop, the global $post is changed. On cache hit, the loop doesn't run, so $post stays correct. But on cache miss, make sure wp_reset_postdata() is called after the loop to restore the global $post.

Fragment caching naturally handles this — on cache hit, no loop runs and $post is untouched.

3. Object Cache Size Limits

Redis/Memcached have per-key size limits (typically 1MB). A single cached HTML fragment is usually a few KB at most, but the ACF options blob could grow large if you have many image fields with full attachment arrays.

Monitor with: wp cache get acf_options_v1 your_cache_group via WP-CLI.

4. Cache Stampede

When a popular cache key expires, many simultaneous requests try to regenerate it at once, all hitting the DB. This is a "stampede" or "thundering herd" problem.

Mitigation: Use reasonable TTLs (not too short) and event-based invalidation. For extremely high-traffic fragments, consider a "stale-while-revalidate" pattern using a lock key, but this is rarely needed on WordPress.

5. Nested Output Buffers

If you nest ob_start() calls (e.g., a cached partial includes another cached partial), each buffer captures independently. This works correctly — inner ob_get_clean() returns only the inner content, outer captures everything including the inner output.

However, if a cached inner partial returns early (cache hit → echo $cached; return;), the outer buffer still captures that output correctly. No issues here, just be aware of the nesting.

6. Caching in Development

Without a persistent object cache, wp_cache_* only persists for the current request. Your fragment cache will miss on every page load, adding slight overhead from ob_start()/ob_get_clean() with no benefit.

Solution: Use the wp_using_ext_object_cache() guard to skip caching entirely on local dev:

if ( wp_using_ext_object_cache() ):
  // cache logic
endif;

Or use the database fallback pattern so caching works locally too (useful for testing invalidation logic).


Quick Reference

Fragment Cache Template

<?php
  // Guard
  if ( wp_using_ext_object_cache() ):
    $cache_key = 'prefix_' . get_the_ID();
    $cached    = wp_cache_get( $cache_key, Your_Class::CACHE_GROUP );
    if ( $cached !== false ):
      echo $cached;
      return;
    endif;
  endif;

  ob_start();
?>
  <!-- template HTML -->
<?php
  $output = ob_get_clean();
  echo $output;

  if ( wp_using_ext_object_cache() )
    wp_cache_set( $cache_key, $output, Your_Class::CACHE_GROUP, HOUR_IN_SECONDS );

ACF Options Cache Hooks

acf/pre_load_value  → Skip DB on cache hit (return sentinel)
acf/format_value    → Return cached value or capture new one (priority 999)
shutdown            → Persist dirty cache blob
acf/save_post       → Invalidate on options page save

Key WordPress Functions

wp_using_ext_object_cache()  // Is persistent cache available?
wp_cache_get( $key, $group ) // Read from cache (false = miss)
wp_cache_set( $key, $value, $group, $ttl ) // Write to cache
wp_cache_delete( $key, $group ) // Delete from cache
is_admin()                   // Are we in wp-admin?
wp_is_post_revision( $id )   // Is this a revision save?

WP_Query Optimization Flags

'no_found_rows'          => true   // Skip SQL_CALC_FOUND_ROWS (no pagination)
'update_post_meta_cache' => false  // Skip meta cache priming (don't need post meta)
'update_post_term_cache' => false  // Skip term cache priming (don't need categories)
'fields'                 => 'ids'  // Return only post IDs

Further Reading

  • 10up Engineering Best Practices — PHP — Covers broader WordPress performance patterns beyond caching: avoiding posts_per_page => -1, filtering in PHP instead of SQL (post__not_in), multi-dimensional query costs, cache priming on write, options table hygiene, avoiding DB writes on frontend, and using Rewrite Rules API over admin-ajax.php for frontend requests.
  • Query Monitor — Essential tool for measuring DB queries, slow queries, duplicates, and object cache hit/miss ratios.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment