Skip to content

Bug: enable_asset_timestamp does not invalidate cached assets on file changes #4049

@NoNoNo

Description

@NoNoNo

Summary

The system.assets.enable_asset_timestamp setting is misleadingly named. When enabled, it appends a query string to asset URLs that implies per-file cache busting (a "timestamp"), but the value is a global cache key that only changes when Grav configuration or version changes — never when individual JS/CSS files change on disk.

Combined with long-lived Cache-Control headers, this causes browsers to serve stale assets indefinitely after deployment.

Expected Behavior

When enable_asset_timestamp: true:

user/themes/july/js/script.js   (modified at 14:00)
  → script.js?1774017600          (or similar mtime-based hash)

user/themes/july/js/script.js   (modified at 15:00)
  → script.js?1774021200          (different value, cache invalidated)

A developer edits script.js, deploys, and browsers fetch the new version.

Actual Behavior

When enable_asset_timestamp: true:

user/themes/july/js/script.js   (modified at 14:00)
  → script.js?g-5fb7ccf4

user/themes/july/js/script.js   (modified at 15:00)
  → script.js?g-5fb7ccf4          (unchanged — same value)

The query string does not change. Browsers serve the old file from cache.

Root Cause

Grav\Common\Assets.php:159-161:

if ($this->enable_asset_timestamp) {
    $this->timestamp = Grav::instance()['cache']->getKey();
}

getKey() returns the global cache key, computed once in Grav\Common\Cache.php:154-157:

$prefix = $this->config->get('system.cache.prefix');       // default: 'g'
$uniqueness = substr(md5($uri->rootUrl(true) . $this->config->key() . GRAV_VERSION), 2, 8);
$this->key = ($prefix ?: 'g') . '-' . $uniqueness;         // e.g. 'g-5fb7ccf4'

This hash is derived from:

  • rootUrl — the site's base URL
  • config key — fingerprint of the merged YAML configuration
  • GRAV_VERSION — the Grav version string

None of these change when a JS/CSS file is edited.

Why This Is Dangerous

Many production configurations — including Grav's own documentation recommendations — serve assets with the g- query string using aggressive caching headers:

Cache-Control: public, max-age=31536000, immutable

The immutable directive tells the browser: never revalidate, serve from cache until the URL changes. The browser trusts this because the URL contains what appears to be a unique, changing token (g-5fb7ccf4).

But the token doesn't change on file edits. The result:

Scenario Browser behavior
User visits site, loads script.js?g-5fb7ccf4 Caches it for 1 year (immutable)
Developer deploys updated script.js New file on server
User visits site again Same URL script.js?g-5fb7ccf4
Browser sees immutable, serves from cache Stale JS served
Bug manifests: broken functionality User sees old behavior

The user has no way to recover except clearing browser cache manually. This can affect hundreds or thousands of users after every deployment that touches JS/CSS.

Reproduction

  1. Enable asset timestamps in user/config/system.yaml:

    assets:
      enable_asset_timestamp: true
  2. Note the URL of a JS file in page source:

    /user/themes/july/js/script.js?g-5fb7ccf4
    
  3. Edit user/themes/july/js/script.js (add a comment or change functionality).

  4. Reload the page. The URL is still:

    /user/themes/july/js/script.js?g-5fb7ccf4
    
  5. Clear Grav cache (bin/grav cache --all). The URL is still:

    /user/themes/july/js/script.js?g-5fb7ccf4
    
  6. The only way to change the URL is to edit a YAML config file or update Grav itself.

Proposed Fix

Option A: Per-file mtime-based timestamp (recommended)

Replace the global cache key with per-file modification time:

// In renderQueryString() or equivalent
if ($this->enable_asset_timestamp) {
    $file = GRAV_ROOT . '/' . $this->asset;
    if (file_exists($file)) {
        $this->timestamp = dechex(filemtime($file));
    }
}

This gives each asset a unique, content-aware hash that changes on every file edit.

I prefer human-readable (and easy to debug) timestamp strings:

        $this->timestamp = date('Ymd-His', filemtime($file));
        // gives a nice `20260101-123456`

Ideally the cache string is a hash – md5 or xxHash/xxh3 (since PHP 8.1) – over the assets content: Same hash = same content = caching forever, live’s good.

Option B: Hash of all asset mtimes

Compute a single hash from the modification times of all registered assets. Changes when any file changes, but not per-file. Simpler than A but coarser.

Option C: Rename the feature

If the global cache key is intentional behavior, rename the config key and documentation to accurately describe what it does:

  • enable_asset_timestampenable_cache_key
  • Document that it invalidates on config/version change only
  • Document that developers must use a build step to rename files or use content-hashed filenames

Additional Notes

The name "timestamp" strongly implies filemtime() behavior. Any developer enabling this setting would reasonably expect it to bust cache on file changes. The current behavior silently fails to do so, creating a latent bug that only manifests in production with long cache headers.

The setting defaults to false, which may mask the issue during development (assets are not cached aggressively without the query string). Problems only appear when enable_asset_timestamp: true is deployed alongside immutable cache headers — exactly the scenario the feature is meant to enable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions