Skip to content

Static caching returns 304 without client cache headers, causing 0-byte downloads #13652

@mynetx

Description

@mynetx

Bug description

When using static caching with the half strategy (application caching), the middleware returns HTTP 304 (Not Modified) responses to clients that haven't sent any cache validation headers (If-None-Match or If-Modified-Since). This causes browsers like Safari to download a 0-byte file instead of rendering the page.

Expected behavior: Requests without cache validation headers should receive HTTP 200 with full content.

Actual behavior: Requests without cache validation headers receive HTTP 304 with no content, causing Safari to download 0 bytes instead of rendering the page.

How to reproduce

  1. Enable static caching: STATAMIC_STATIC_CACHING_STRATEGY=half in .env
  2. Visit a page multiple times to populate the application cache
  3. Clear browser cache or visit from a different browser/client
  4. Make a request without cache validation headers:
    curl -I https://example.com
    
  5. Observe inappropriate HTTP 304 response instead of HTTP 200
  6. In Safari, the page will download as a 0-byte file instead of rendering

Temporary workaround: Running php artisan cache:clear resolves the issue until cache state becomes corrupted again.

Environment

Environment
Application Name: [Redacted]
Laravel Version: 12.47.0
PHP Version: 8.4.16
Composer Version: 2.9.3
Environment: production
Debug Mode: OFF
URL: [redacted]
Maintenance Mode: OFF
Timezone: UTC
Locale: de

Cache
Config: CACHED
Events: CACHED
Routes: CACHED
Views: CACHED

Drivers
Broadcasting: log
Cache: file
Database: mysql
Logs: stack / single
Mail: smtp
Queue: redis
Session: file

Storage
public/storage: NOT LINKED

Statamic
Addons: 1
Sites: 1
Stache Watcher: Disabled (auto)
Static Caching: half
Version: 5.72.0 Solo

Statamic Addons
statamic-rad-pack/runway: 8.9.0

Installation

Existing Laravel app


Root Cause Analysis

The issue is in vendor/statamic/cms/src/StaticCaching/Middleware/Cache.php at lines 235-244:

private function addEtagToResponse($request, $response)
{
    if (! $response->isRedirect() && $content = $response->getContent()) {
        $response
            ->setEtag(md5($content))
            ->isNotModified($request);  // ← Called unconditionally
    }
    return $response;
}

Problem: isNotModified() is called on every cached response, regardless of whether the request contains cache validation headers.

Hypothesis: When ApplicationCacher caches a response (ApplicationCacher.php line 48-50), it stores headers but only excludes:

->reject(fn ($value, $key) => in_array($key, ['date', 'x-powered-by', 'cache-control', 'expires', 'set-cookie']))

Notice that etag and last-modified are not excluded. If a cached response has these headers from a previous request, they may interfere with fresh ETag generation and cause isNotModified() to incorrectly return true.

Context

This bug is related to the ETag support feature introduced in:

This appears to be a new edge case in the ETag implementation that differs from #13425.

Suggested Fix

Option 1: Only call isNotModified() when request has cache validation headers:

private function addEtagToResponse($request, $response)
{
    if (! $response->isRedirect() && $content = $response->getContent()) {
        // Clear potentially stale headers
        $response->headers->remove('ETag');
        $response->headers->remove('Last-Modified');
        
        // Set fresh ETag
        $response->setEtag(md5($content));
        
        // Only call isNotModified() if request has cache validation headers
        if ($request->headers->has('If-None-Match') || $request->headers->has('If-Modified-Since')) {
            $response->isNotModified($request);
        }
    }
    return $response;
}

Option 2: Exclude etag and last-modified from cached headers in ApplicationCacher.php line 49:

->reject(fn ($value, $key) => in_array($key, [
    'date', 'x-powered-by', 'cache-control', 'expires', 'set-cookie',
    'etag', 'last-modified'  // Add these
]))

Workaround

We've implemented a custom middleware that wraps Statamic's caching logic with the fix from Option 1. Tested and verified:

  • ✅ Request without cache headers → 200 OK
  • ✅ Request with If-None-Match → 304 Not Modified
  • ✅ ETag functionality preserved for legitimate conditional requests

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions