Response Caching & Conditional Requests

Layer fine‑grained dynamic caching and validator logic on top of (or independent from) file server level cache headers. This sample demonstrates in‑memory response caching limits plus conditional 304 handling for two dynamic endpoints. See the in-depth HTTP Caching guide for architecture, trade‑offs, and parameter tables.

Prerequisites: see Introduction.

Full source

File: pwsh/tutorial/examples/3.6-Response-Caching.ps1

<#
    Sample Kestrun Server demonstrating response caching and conditional requests (ETag / Last-Modified).
    Shows how to configure response caching limits and a simple route that returns 304 when unchanged.
    FileName: 3.5-Response-Caching.ps1
#>

param(
    [int]$Port = 5000,
    [IPAddress]$IPAddress = [IPAddress]::Loopback
)

# Initialize Kestrun root directory
# the default value is $PWD
# This is recommended in order to use relative paths without issues
Initialize-KrRoot -Path $PSScriptRoot
New-KrLogger |
    Set-KrLoggerLevel -Value Debug |
    Add-KrSinkConsole |
    Register-KrLogger -Name 'console' -SetAsDefault | Out-Null
# Create a new Kestrun server
New-KrServer -Name "Simple Server"

# Add a listener on configured port and IP
Add-KrEndpoint -Port $Port -IPAddress $IPAddress

# Add a file server with browsing enabled use the default Cache-Control headers
Add-KrFileServerMiddleware -RequestPath '/' -RootPath '.\Assets\wwwroot' -EnableDirectoryBrowsing -ContentTypeMap $map

# Add response caching with a 10MB size limit, 64KB max body size, case-sensitive paths,
# a shared max age of 100 seconds, and public cacheability.
Add-KrCacheMiddleware -SizeLimit 10485760 -MaximumBodySize 65536 -UseCaseSensitivePaths -SharedMaxAge 100 -Public -MaxAge 100

# Enable Kestrun configuration
Enable-KrConfiguration

# Map a route that demonstrates response caching with ETag and Last-Modified support
Add-KrMapRoute -Verbs Get -Pattern '/cachetest' -ScriptBlock {
    $payload = "This is a cached response."  # keep it stable if you want 304s
    $etag = '"' + ([Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($payload))).ToLower()) + '"'
    # Check for conditional request; if a 304 was sent, just return
    if ((Test-KrCacheRevalidation -Payload $payload -ETag $etag -LastModified (Get-Date '2023-01-01'))) {
        return
    }
    # Fresh response
    Write-KrTextResponse -InputObject $payload -StatusCode 200
}


# Map another route that demonstrates response caching with ETag and Last-Modified support
# but also adds Cache-Control headers to make it private and must-revalidate (so browsers will revalidate)
# overrides the middleware defaults
Add-KrMapRoute -Verbs Get -Pattern '/custom_cachetest' -ScriptBlock {
    Add-KrCacheResponse -Private -MaxAge 120 -MustRevalidate
    $payload = "This is a cached response."  # keep it stable if you want 304s
    $etag = '"' + ([Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($payload))).ToLower()) + '"'
    # Check for conditional request; if a 304 was sent, just return
    if ((Test-KrCacheRevalidation -Payload $payload -ETag $etag -LastModified (Get-Date '2023-01-01'))) {
        Write-KrLog -Level Debug -Message "Returning 304 Not Modified"
        return
    }
    Write-KrLog -Level Debug -Message "Returning 200 OK with payload: {Payload}" -Values $payload
    # Fresh response
    Write-KrTextResponse -InputObject $payload -StatusCode 200
}

# Start the server asynchronously
Start-KrServer

What this sample shows

  • Registering response caching with custom memory + body size constraints
  • Using Test-KrCacheRevalidation to automatically emit 304 Not Modified
  • Emitting a deterministic payload + hash-based ETag (route: /cachetest)
  • Overriding cache headers per response with Add-KrCacheResponse (route: /custom_cachetest)
  • Combining file server defaults (previous chapter) with dynamic route validators

Step-by-step

  1. Root + logging: Standard Initialize-KrRoot + console logger registration.
  2. Server + listener: Create server; add loopback listener (port 5000).
  3. File server: Mount root with optional directory browsing (inherits any broad cache directives from previous chapter if you choose).
  4. Response cache middleware: Add-KrCacheMiddleware -SizeLimit 10485760 -MaximumBodySize 65536 -UseCaseSensitivePaths -SharedMaxAge 100 -Public -MaxAge 100.
  5. /cachetest: Stable payload + computed SHA256 → build explicit ETag, call Test-KrCacheRevalidation; skip body if $true.
  6. /custom_cachetest: Adds per-response overrides using Add-KrCacheResponse -Private -MaxAge 120 -MustRevalidate before validator logic.
  7. Start: enable configuration then start server.

How conditional caching works

Test-KrCacheRevalidation:

  1. Compares supplied / computed ETag vs If-None-Match (supports weak tags when -Weak).
  2. Compares supplied -LastModified vs If-Modified-Since.
  3. Writes 304 Not Modified + validators + cache headers when resource unchanged and returns $true.
  4. Otherwise returns $false; you write fresh body and (optionally) headers (which may also be cached in-memory subject to size limits).

ETag strategy tips:

  • Stable immutable body → hash bytes (cheap, deterministic)
  • Frequently changing body → version counter or timestamp string
  • Composite data → hash only revision identifiers instead of full concatenated document

Try it

Run the sample; then test both endpoints:

# First request (200 OK + body)
Invoke-WebRequest -Uri 'http://127.0.0.1:5000/cachetest' | Select-Object StatusCode,Headers,@{n='Len';e={$_.Content.Length}}
# Second request likely 304 (short body; ETag stable)
Invoke-WebRequest -Uri 'http://127.0.0.1:5000/cachetest' | Select-Object StatusCode,Headers

# Custom per-response overrides (private + must-revalidate)
Invoke-WebRequest -Uri 'http://127.0.0.1:5000/custom_cachetest' | Select-Object StatusCode,Headers
Invoke-WebRequest -Uri 'http://127.0.0.1:5000/custom_cachetest' -Headers @{ 'If-None-Match' = '"bogusetag"' } | Select-Object StatusCode,Headers

Curl:

curl -i http://127.0.0.1:5000/cachetest
curl -i http://127.0.0.1:5000/custom_cachetest

Middleware tuning parameters

Parameter Purpose Guidance
-SizeLimit Total bytes across all cached entries Keep modest; very large caches increase memory pressure
-MaximumBodySize Skip caching bodies above this threshold Set near typical response size; prevents large blobs from evicting many
-UseCaseSensitivePaths Distinguish path casing Useful on Linux; optional on Windows
-MaxAge / -SharedMaxAge Freshness lifetimes (private vs shared caches) Short for rapidly changing content; longer for immutable assets
-Public / -Private Cacheability scope Use -Public only if responses are identical for all users

Best practices

  • Keep payload hashes stable for content that rarely changes to maximize 304 hits.
  • Avoid caching highly personalized or security‑sensitive responses.
  • Use a versioned path or ETag strategy when deploying updated static bundles (app.v2.js).
  • Monitor log volume at Debug/Verbose levels; reduce when not actively diagnosing.

Troubleshooting

Symptom Cause Fix
Always 200 responses ETag not stable / last-mod date shifts Ensure payload + date inputs are deterministic
No 304s with curl Missing If-None-Match header Provide it manually (clients add automatically after first call)
Large body uncached Exceeds -MaximumBodySize Increase threshold if appropriate
Memory growth Cache size too large Lower -SizeLimit or reduce max age

References


Additional examples

Weak ETag (prefix W/ automatically when using -Weak with explicit -ETag in validators):

Add-KrMapRoute -Pattern '/weak' -Verbs GET -ScriptBlock {
    $payload = (Get-Date).ToUniversalTime().ToString('yyyyMMddHHmm')
    if (-not (Test-KrCacheRevalidation -ETag 'v1' -Weak)) {
        Write-KrTextResponse $payload
    }
}

Last-Modified only (no ETag):

Add-KrMapRoute -Pattern '/modified' -Verbs GET -ScriptBlock {
    if (-not (Test-KrCacheRevalidation -Payload 'static-token' -LastModified (Get-Date '2024-01-01'))) {
        Write-KrTextResponse 'unchanged since 2024'
    }
}

Private + no-store (sensitive ephemeral):

Add-KrMapRoute -Pattern '/secret' -Verbs GET -ScriptBlock {
    Add-KrCacheResponse -NoStore -Private
    Write-KrTextResponse 'one-time view'
}

Previous / Next

Go back to File Server Caching Headers or continue to Variable Routes.