diff --git a/plugins/performance-lab/includes/site-health/far-future-headers/helper.php b/plugins/performance-lab/includes/site-health/far-future-headers/helper.php
new file mode 100644
index 0000000000..88b2d322a8
--- /dev/null
+++ b/plugins/performance-lab/includes/site-health/far-future-headers/helper.php
@@ -0,0 +1,320 @@
+ __( 'Your site serves static assets with an effective caching strategy', 'performance-lab' ),
+ 'status' => 'good',
+ 'badge' => array(
+ 'label' => __( 'Performance', 'performance-lab' ),
+ 'color' => 'blue',
+ ),
+ 'description' => sprintf(
+ '
%s
',
+ esc_html__(
+ 'Serving static assets with far-future expiration headers improves performance by allowing browsers to cache files for a long time, reducing repeated requests.',
+ 'performance-lab'
+ )
+ ),
+ 'actions' => '',
+ 'test' => 'is_far_future_headers_enabled',
+ );
+
+ // List of assets to check.
+ $assets = array(
+ includes_url( 'js/wp-embed.min.js' ),
+ includes_url( 'css/buttons.min.css' ),
+ includes_url( 'fonts/dashicons.woff2' ),
+ includes_url( 'images/media/video.png' ),
+ );
+
+ /**
+ * Filters the list of assets to check for far-future headers.
+ *
+ * @since n.e.x.t
+ *
+ * @param string[] $assets List of asset URLs to check.
+ */
+ $assets = apply_filters( 'perflab_ffh_assets_to_check', $assets );
+ $assets = array_filter( (array) $assets, 'is_string' );
+
+ // Check if far-future headers are enabled for all assets.
+ $results = perflab_ffh_check_assets( $assets );
+
+ if ( 'good' !== $results['final_status'] ) {
+ $result['status'] = $results['final_status'];
+ $result['label'] = __( 'Your site does not serve static assets with an effective caching strategy', 'performance-lab' );
+
+ if ( count( $results['details'] ) > 0 ) {
+ $result['actions'] = sprintf(
+ '%s
%s%s
',
+ esc_html__( 'The following file types do not have the recommended far-future headers. Consider adding or adjusting Cache-Control or Expires headers for these asset types.', 'performance-lab' ),
+ perflab_ffh_get_extensions_table( $results['details'] ),
+ esc_html__( 'Note: "Conditionally cached" means that the browser can re-validate the resource using ETag or Last-Modified headers. This results in fewer full downloads but still requires the browser to make requests, unlike far-future expiration headers that allow the browser to fully rely on its local cache for a longer duration.', 'performance-lab' )
+ );
+ }
+ $result['actions'] .= sprintf(
+ '%s
',
+ esc_html__( 'Far-future Cache-Control or Expires headers can be added or adjusted with a small configuration change by your hosting provider.', 'performance-lab' )
+ );
+ }
+
+ return $result;
+}
+
+/**
+ * Checks if far-future expiration headers are enabled for a list of assets.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param string[] $assets List of asset URLs to check.
+ * @return array{final_status: string, details: array{filename: string, reason: string}[]} Final status and details.
+ */
+function perflab_ffh_check_assets( array $assets ): array {
+ $final_status = 'good';
+ $fail_details = array(); // Array of arrays with 'filename' and 'reason'.
+
+ foreach ( $assets as $asset ) {
+ $response = wp_remote_get( $asset, array( 'sslverify' => false ) );
+
+ // Extract filename from the URL.
+ $path_info = pathinfo( (string) wp_parse_url( $asset, PHP_URL_PATH ) );
+ $filename = $path_info['basename'] ?? basename( $asset );
+
+ if ( is_wp_error( $response ) ) {
+ // Can't determine headers if request failed, consider it a fail.
+ $final_status = 'recommended';
+ $fail_details[] = array(
+ 'filename' => $filename,
+ 'reason' => __( 'Could not retrieve headers', 'performance-lab' ),
+ );
+ continue;
+ }
+
+ $headers = wp_remote_retrieve_headers( $response );
+ if ( ! is_object( $headers ) && 0 === count( $headers ) ) {
+ // No valid headers retrieved.
+ $final_status = 'recommended';
+ $fail_details[] = array(
+ 'filename' => $filename,
+ 'reason' => __( 'No valid headers retrieved', 'performance-lab' ),
+ );
+ continue;
+ }
+
+ $check = perflab_ffh_check_headers( $headers );
+ if ( isset( $check['passed'] ) && $check['passed'] ) {
+ // This asset passed far-future headers test, no action needed.
+ continue;
+ }
+
+ // If not passed, decide whether to try conditional request.
+ if ( false === $check ) {
+ // Only if no far-future headers at all, we try conditional request.
+ $conditional_pass = perflab_ffh_try_conditional_request( $asset, $headers );
+ $final_status = 'recommended';
+ if ( ! $conditional_pass ) {
+ $fail_details[] = array(
+ 'filename' => $filename,
+ 'reason' => __( 'No far-future headers and no conditional caching', 'performance-lab' ),
+ );
+ } else {
+ $fail_details[] = array(
+ 'filename' => $filename,
+ 'reason' => __( 'No far-future headers but conditionally cached', 'performance-lab' ),
+ );
+ }
+ } else {
+ // If there's a max-age or expires but below threshold, we skip conditional.
+ $final_status = 'recommended';
+ $fail_details[] = array(
+ 'filename' => $filename,
+ 'reason' => $check['reason'],
+ );
+ }
+ }
+
+ return array(
+ 'final_status' => $final_status,
+ 'details' => $fail_details,
+ );
+}
+
+/**
+ * Checks if far-future expiration headers are enabled.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param WpOrg\Requests\Utility\CaseInsensitiveDictionary|array> $headers Response headers.
+ * @return array{passed: bool, reason: string}|false Detailed result. If passed=false, reason explains why it failed and false if no headers found.
+ */
+function perflab_ffh_check_headers( $headers ) {
+ /**
+ * Filters the threshold for far-future headers.
+ *
+ * @since n.e.x.t
+ *
+ * @param int $threshold Threshold in seconds.
+ */
+ $threshold = apply_filters( 'perflab_far_future_headers_threshold', YEAR_IN_SECONDS );
+
+ $cache_control = $headers['cache-control'] ?? '';
+ $expires = $headers['expires'] ?? '';
+
+ // Check Cache-Control header for max-age.
+ $max_age = 0;
+ if ( '' !== $cache_control ) {
+ // There can be multiple cache-control headers, we only care about max-age.
+ foreach ( (array) $cache_control as $control ) {
+ if ( 1 === preg_match( '/max-age\s*=\s*(\d+)/', $control, $matches ) ) {
+ $max_age = (int) $matches[1];
+ break;
+ }
+ }
+ }
+
+ // If max-age meets or exceeds the threshold, we consider it good.
+ if ( $max_age >= $threshold ) {
+ return array(
+ 'passed' => true,
+ 'reason' => '',
+ );
+ }
+
+ // If max-age is too low or not present, check Expires.
+ if ( is_string( $expires ) && '' !== $expires ) {
+ $expires_time = strtotime( $expires );
+ $remaining_time = is_int( $expires_time ) ? $expires_time - time() : 0;
+ if ( $remaining_time >= $threshold ) {
+ // Good - Expires far in the future.
+ return array(
+ 'passed' => true,
+ 'reason' => '',
+ );
+ }
+
+ // Expires header exists but not far enough in the future.
+ if ( $max_age > 0 ) {
+ return array(
+ 'passed' => false,
+ 'reason' => sprintf(
+ /* translators: 1: actual max-age value in seconds, 2: threshold in seconds */
+ __( 'max-age below threshold (actual: %1$s seconds, threshold: %2$s seconds)', 'performance-lab' ),
+ number_format_i18n( $max_age ),
+ number_format_i18n( $threshold )
+ ),
+ );
+ }
+ return array(
+ 'passed' => false,
+ 'reason' => sprintf(
+ /* translators: 1: actual Expires header value in seconds, 2: threshold in seconds */
+ __( 'expires below threshold (actual: %1$s seconds, threshold: %2$s seconds)', 'performance-lab' ),
+ number_format_i18n( $remaining_time ),
+ number_format_i18n( $threshold )
+ ),
+ );
+ }
+
+ // No max-age or expires found at all or max-age < threshold and no expires.
+ if ( 0 === $max_age ) {
+ return false;
+ } else {
+ // max-age was present but below threshold and no expires.
+ return array(
+ 'passed' => false,
+ 'reason' => sprintf(
+ /* translators: 1: actual max-age value in seconds, 2: threshold in seconds */
+ __( 'max-age below threshold (actual: %1$s seconds, threshold: %2$s seconds)', 'performance-lab' ),
+ number_format_i18n( $max_age ),
+ number_format_i18n( $threshold )
+ ),
+ );
+ }
+}
+
+/**
+ * Attempt a conditional request with ETag/Last-Modified.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param string $url The asset URL.
+ * @param WpOrg\Requests\Utility\CaseInsensitiveDictionary|array> $headers The initial response headers.
+ * @return bool True if a 304 response was received.
+ */
+function perflab_ffh_try_conditional_request( string $url, $headers ): bool {
+ $etag = isset( $headers['etag'] ) ? $headers['etag'] : '';
+ $last_modified = isset( $headers['last-modified'] ) ? $headers['last-modified'] : '';
+
+ $conditional_headers = array();
+ if ( '' !== $etag ) {
+ $conditional_headers['If-None-Match'] = $etag;
+ }
+ if ( '' !== $last_modified ) {
+ $conditional_headers['If-Modified-Since'] = $last_modified;
+ }
+
+ $response = wp_remote_get(
+ $url,
+ array(
+ 'sslverify' => false,
+ 'headers' => $conditional_headers,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return false;
+ }
+
+ $status_code = wp_remote_retrieve_response_code( $response );
+ return ( 304 === $status_code );
+}
+
+/**
+ * Generate a table listing files that need far-future headers, including reasons.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param array $fail_details Array of arrays with 'filename' and 'reason'.
+ * @return string HTML formatted table.
+ */
+function perflab_ffh_get_extensions_table( array $fail_details ): string {
+ $html_table = sprintf(
+ '%s | %s |
',
+ esc_html__( 'File', 'performance-lab' ),
+ esc_html__( 'Status', 'performance-lab' )
+ );
+
+ foreach ( $fail_details as $detail ) {
+ $html_table .= sprintf(
+ '%s | %s |
',
+ esc_html( $detail['filename'] ),
+ esc_html( $detail['reason'] )
+ );
+ }
+
+ $html_table .= '
';
+
+ return $html_table;
+}
diff --git a/plugins/performance-lab/includes/site-health/far-future-headers/hooks.php b/plugins/performance-lab/includes/site-health/far-future-headers/hooks.php
new file mode 100644
index 0000000000..ede165b4bc
--- /dev/null
+++ b/plugins/performance-lab/includes/site-health/far-future-headers/hooks.php
@@ -0,0 +1,29 @@
+} $tests Site Health Tests.
+ * @return array{direct: array} Amended tests.
+ */
+function perflab_ffh_add_test( array $tests ): array {
+ $tests['direct']['far_future_headers'] = array(
+ 'label' => __( 'Effective Caching Headers', 'performance-lab' ),
+ 'test' => 'perflab_ffh_assets_test',
+ );
+ return $tests;
+}
+add_filter( 'site_status_tests', 'perflab_ffh_add_test' );
diff --git a/plugins/performance-lab/includes/site-health/load.php b/plugins/performance-lab/includes/site-health/load.php
index e4cd71a596..ac0032cf87 100644
--- a/plugins/performance-lab/includes/site-health/load.php
+++ b/plugins/performance-lab/includes/site-health/load.php
@@ -29,3 +29,7 @@
// AVIF headers site health check.
require_once __DIR__ . '/avif-headers/helper.php';
require_once __DIR__ . '/avif-headers/hooks.php';
+
+// Far-Future Headers site health check.
+require_once __DIR__ . '/far-future-headers/helper.php';
+require_once __DIR__ . '/far-future-headers/hooks.php';
diff --git a/plugins/performance-lab/tests/includes/site-health/far-future-headers/test-far-future-headers.php b/plugins/performance-lab/tests/includes/site-health/far-future-headers/test-far-future-headers.php
new file mode 100644
index 0000000000..ba05c58425
--- /dev/null
+++ b/plugins/performance-lab/tests/includes/site-health/far-future-headers/test-far-future-headers.php
@@ -0,0 +1,203 @@
+>
+ */
+ protected $mocked_responses = array();
+
+ /**
+ * Setup each test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ // Clear any filters or mocks.
+ remove_all_filters( 'pre_http_request' );
+
+ // Add the filter to mock HTTP requests.
+ add_filter( 'pre_http_request', array( $this, 'mock_http_requests' ), 10, 3 );
+ }
+
+ /**
+ * Test that when all assets have valid far-future headers, the status is "good".
+ *
+ * @covers ::perflab_ffh_assets_test
+ */
+ public function test_all_assets_valid_far_future_headers(): void {
+ // Mock responses: all assets have a max-age > 1 year (threshold).
+ $this->mocked_responses = array(
+ includes_url( 'js/wp-embed.min.js' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( YEAR_IN_SECONDS + 1000 ) ) ),
+ includes_url( 'css/buttons.min.css' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( YEAR_IN_SECONDS + 500 ) ) ),
+ includes_url( 'fonts/dashicons.woff2' ) => $this->build_response( 200, array( 'expires' => gmdate( 'D, d M Y H:i:s', time() + YEAR_IN_SECONDS + 1000 ) . ' GMT' ) ),
+ includes_url( 'images/media/video.png' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( YEAR_IN_SECONDS + 2000 ) ) ),
+ );
+
+ $result = perflab_ffh_assets_test();
+ $this->assertEquals( 'good', $result['status'] );
+ $this->assertEmpty( $result['actions'] );
+ }
+
+ /**
+ * Test that when an asset has no far-future headers but has conditional caching (ETag/Last-Modified), status is 'recommended'.
+ *
+ * @covers ::perflab_ffh_assets_test
+ */
+ public function test_assets_conditionally_cached(): void {
+ // For conditional caching scenario, setting etag/last-modified headers.
+ $this->mocked_responses = array(
+ 'js/wp-embed.min.js' => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( YEAR_IN_SECONDS + 1000 ) ) ),
+ 'css/buttons.min.css' => $this->build_response( 200, array( 'etag' => '"123456789"' ) ),
+ 'fonts/dashicons.woff2' => $this->build_response( 200, array( 'last-modified' => gmdate( 'D, d M Y H:i:s', time() - 1000 ) . ' GMT' ) ),
+ 'images/media/video.png' => $this->build_response(
+ 200,
+ array(
+ 'etag' => '"123456789"',
+ 'last-modified' => gmdate( 'D, d M Y H:i:s', time() - 1000 ) . ' GMT',
+ )
+ ),
+ 'conditional_304' => $this->build_response( 304 ),
+ );
+
+ $result = perflab_ffh_assets_test();
+ $this->assertEquals( 'recommended', $result['status'] );
+ $this->assertNotEmpty( $result['actions'] );
+ }
+
+ /**
+ * Test that different status messages are returned based on the test results.
+ *
+ * @covers ::perflab_ffh_check_assets
+ */
+ public function test_status_messages(): void {
+ $this->mocked_responses = array(
+ includes_url( 'js/wp-embed.min.js' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( YEAR_IN_SECONDS - 1000 ) ) ),
+ includes_url( 'css/buttons.min.css' ) => $this->build_response( 200, array( 'expires' => gmdate( 'D, d M Y H:i:s', time() + YEAR_IN_SECONDS - 1000 ) . ' GMT' ) ),
+ includes_url( 'fonts/dashicons.woff2' ) => $this->build_response( 200, array( 'etag' => '"123456789"' ) ),
+ includes_url( 'images/media/video.png' ) => $this->build_response( 200, array() ),
+ 'conditional_304' => $this->build_response( 304 ),
+ );
+
+ $result = perflab_ffh_check_assets(
+ array(
+ includes_url( 'js/wp-embed.min.js' ),
+ includes_url( 'css/buttons.min.css' ),
+ includes_url( 'fonts/dashicons.woff2' ),
+ includes_url( 'images/media/video.png' ),
+ )
+ );
+
+ $this->assertEquals( 'recommended', $result['final_status'] );
+ $this->assertStringContainsString( 'max-age below threshold (actual:', $result['details'][0]['reason'] );
+ $this->assertStringContainsString( 'expires below threshold (actual:', $result['details'][1]['reason'] );
+ $this->assertEquals( 'No far-future headers but conditionally cached', $result['details'][2]['reason'] );
+ $this->assertEquals( 'No far-future headers and no conditional caching', $result['details'][3]['reason'] );
+ }
+
+ /**
+ * Test that the filter `perflab_ffh_assets_to_check` and `perflab_far_future_headers_threshold` are working as expected.
+ *
+ * @covers ::perflab_ffh_check_assets
+ */
+ public function test_filters(): void {
+ add_filter(
+ 'perflab_ffh_assets_to_check',
+ static function ( $assets ) {
+ $assets[] = includes_url( 'images/blank.gif' );
+ return $assets;
+ }
+ );
+
+ add_filter(
+ 'perflab_far_future_headers_threshold',
+ static function () {
+ return 1000;
+ }
+ );
+
+ $this->mocked_responses = array(
+ includes_url( 'js/wp-embed.min.js' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . 1500 ) ),
+ includes_url( 'css/buttons.min.css' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . 500 ) ),
+ includes_url( 'fonts/dashicons.woff2' ) => $this->build_response( 200, array( 'expires' => gmdate( 'D, d M Y H:i:s', time() + 1500 ) . ' GMT' ) ),
+ includes_url( 'images/media/video.png' ) => $this->build_response( 200, array( 'expires' => gmdate( 'D, d M Y H:i:s', time() + 500 ) . ' GMT' ) ),
+ includes_url( 'images/blank.gif' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( 500 ) ) ),
+ );
+
+ $result = perflab_ffh_check_assets(
+ array(
+ includes_url( 'js/wp-embed.min.js' ),
+ includes_url( 'css/buttons.min.css' ),
+ includes_url( 'fonts/dashicons.woff2' ),
+ includes_url( 'images/media/video.png' ),
+ includes_url( 'images/blank.gif' ),
+ )
+ );
+
+ $this->assertEquals( 'recommended', $result['final_status'] );
+ $this->assertStringContainsString( 'max-age below threshold (actual:', $result['details'][0]['reason'] );
+ $this->assertStringContainsString( 'expires below threshold (actual:', $result['details'][1]['reason'] );
+ $this->assertStringContainsString( 'max-age below threshold (actual:', $result['details'][2]['reason'] );
+ }
+
+ /**
+ * Test that when no assets are passed, the status is "good".
+ *
+ * @covers ::perflab_ffh_check_assets
+ */
+ public function test_when_no_assets(): void {
+ $this->mocked_responses = array();
+
+ $result = perflab_ffh_check_assets( array() );
+
+ $this->assertEquals( 'good', $result['final_status'] );
+ $this->assertEmpty( $result['details'] );
+ }
+
+ /**
+ * Mock HTTP requests for assets to simulate different responses.
+ *
+ * @param bool $response A preemptive return value of an HTTP request. Default false.
+ * @param array $args Request arguments.
+ * @param string $url The request URL.
+ * @return array Mocked response.
+ */
+ public function mock_http_requests( bool $response, array $args, string $url ): array {
+ // If conditional headers used in second request, simulate a 304 response.
+ if ( isset( $this->mocked_responses['conditional_304'] ) && ( isset( $args['headers']['If-None-Match'] ) || isset( $args['headers']['If-Modified-Since'] ) ) ) {
+ return $this->mocked_responses['conditional_304'];
+ }
+
+ if ( isset( $this->mocked_responses[ $url ] ) ) {
+ return $this->mocked_responses[ $url ];
+ }
+
+ // If no specific mock set, default to a generic success with no caching.
+ return $this->build_response( 200 );
+ }
+
+ /**
+ * Helper method to build a mock HTTP response.
+ *
+ * @param int $status_code HTTP status code.
+ * @param array $headers HTTP headers.
+ * @return array{response: array{code: int, message: string}, headers: WpOrg\Requests\Utility\CaseInsensitiveDictionary}
+ */
+ protected function build_response( int $status_code = 200, array $headers = array() ): array {
+ return array(
+ 'response' => array(
+ 'code' => $status_code,
+ 'message' => '',
+ ),
+ 'headers' => new WpOrg\Requests\Utility\CaseInsensitiveDictionary( $headers ),
+ );
+ }
+}