I tried to verify when CloudFront's stale-if-error returns stale content during origin timeout

I tried to verify when CloudFront's stale-if-error returns stale content during origin timeout

I confirmed in a reproducible test environment when CloudFront's `stale-if-error` returns stale cache during origin timeouts. While `stale-while-revalidate` returns stale content immediately, I observed that under the timeout conditions in this test, `stale-if-error` waits for the origin fetch to time out before returning stale content, and I will organize the period during which clients can be protected without being kept waiting.
2026.07.22

This page has been translated by machine translation. View original

Introduction

In 2023, DevelopersIO configured stale-if-error on CloudFront origins so that edge cache responses could continue even during origin failures.

https://dev.classmethod.jp/articles/developersio-cdn-cloudfront/

However, during the incident on July 16, 2026, there was a situation where the configured stale-if-error did not appear to be working as expected.

https://dev.classmethod.jp/articles/cloudfront-vpc-origin-incident-20260716-log-analysis/

The configuration was in place, so why weren't users protected? We built a reproduction environment and measured the actual behavior.

The verification results showed that during the stale-while-revalidate period, stale cache is returned immediately with zero client impact, but with origin timeout-type failures, once entering the stale-if-error period, a synchronous wait of OriginReadTimeout seconds occurs on every request.

Verification Details

Verification Environment

We prepared a Lambda Function URL that intentionally sleeps for 30 seconds as the origin, and reproduced timeouts by switching CloudFront's OriginReadTimeout. stale-while-revalidate and stale-if-error are directives added as CloudFront native features in May 2023.

https://aws.amazon.com/about-aws/whats-new/2023/05/amazon-cloudfront-stale-while-revalidate-stale-if-error-cache-control-directives

Item Value
Edge POP NRT12-P9 (Tokyo)
Origin Lambda Function URL (python3.12)
Lambda response time 30 seconds (sleep)
Origin Cache-Control max-age=90, stale-while-revalidate=90, stale-if-error=180
Response headers policy Cache-Control: no-store (Override=true, viewer-facing override)
Cache policy MinTTL=0, DefaultTTL=0, MaxTTL=3600
OriginReadTimeout Phase 1: 60s / Phase 2: 20s
ConnectionAttempts 1
Standard Logging v2 CloudWatch Logs (us-east-1, JSON format)
curl script IP fixed with --resolve, 10s interval

The Cache-Control: no-store (Override=true) in the response headers policy only overrides the viewer-facing response and does not affect edge cache control.

This is the Lambda handler code.

def handler(event, context):
    time.sleep(30)
    now = datetime.now(timezone.utc).isoformat()
    body = json.dumps({"timestamp": now, "message": "OK from slow origin"})
    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "application/json",
            "Cache-Control": "max-age=90, stale-while-revalidate=90, stale-if-error=180"
        },
        "body": body
    }

We fixed the DNS resolution destination IP with curl's --resolve and recorded the Age, X-Cache, x-amz-cf-pop headers and the body timestamp. We confirmed that x-amz-cf-pop was NRT12-P9 for all requests.

# Fix the DNS resolution destination IP for CloudFront domain
RESOLVED_IP=$(dig +short "$DOMAIN" | grep -E '^[0-9]' | head -1)

curl -s \
  --resolve "${DOMAIN}:443:${RESOLVED_IP}" \
  --max-time 65 \
  -w '%{http_code} %{time_total}' \
  -D "$TMPHEADERS" \
  -o "$TMPBODY" \
  "https://${DOMAIN}/"
CloudFormation template (for verification environment setup)
AWSTemplateFormatVersion: '2010-09-09'
Description: CloudFront stale-if-error verification with Lambda Function URL origin

Parameters:
  OriginReadTimeout:
    Type: Number
    Default: 60
    Description: CloudFront origin read timeout in seconds (Phase1=60, Phase2=20)
    MinValue: 1
    MaxValue: 60

Resources:
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${AWS::StackName}-lambda-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

  SlowOriginFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${AWS::StackName}-slow-origin'
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Timeout: 60
      Code:
        ZipFile: |
          import time
          import json
          from datetime import datetime, timezone

          def handler(event, context):
              time.sleep(30)
              now = datetime.now(timezone.utc).isoformat()
              body = json.dumps({"timestamp": now, "message": "OK from slow origin"})
              return {
                  "statusCode": 200,
                  "headers": {
                      "Content-Type": "application/json",
                      "Cache-Control": "max-age=90, stale-while-revalidate=90, stale-if-error=180"
                  },
                  "body": body
              }

  SlowOriginFunctionUrl:
    Type: AWS::Lambda::Url
    Properties:
      AuthType: NONE
      TargetFunctionArn: !GetAtt SlowOriginFunction.Arn

  SlowOriginFunctionUrlPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt SlowOriginFunction.Arn
      Action: lambda:InvokeFunctionUrl
      Principal: '*'
      FunctionUrlAuthType: NONE

  CachePolicy:
    Type: AWS::CloudFront::CachePolicy
    Properties:
      CachePolicyConfig:
        Name: !Sub '${AWS::StackName}-cache-policy'
        MinTTL: 0
        DefaultTTL: 0
        MaxTTL: 3600
        ParametersInCacheKeyAndForwardedToOrigin:
          CookiesConfig:
            CookieBehavior: none
          HeadersConfig:
            HeaderBehavior: none
          QueryStringsConfig:
            QueryStringBehavior: none
          EnableAcceptEncodingGzip: true
          EnableAcceptEncodingBrotli: true

  ResponseHeadersPolicy:
    Type: AWS::CloudFront::ResponseHeadersPolicy
    Properties:
      ResponseHeadersPolicyConfig:
        Name: !Sub '${AWS::StackName}-response-headers'
        CustomHeadersConfig:
          Items:
            - Header: Cache-Control
              Value: no-store
              Override: true

  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Comment: !Sub '${AWS::StackName} - stale-if-error verification'
        DefaultCacheBehavior:
          TargetOriginId: lambda-origin
          ViewerProtocolPolicy: https-only
          CachePolicyId: !Ref CachePolicy
          ResponseHeadersPolicyId: !Ref ResponseHeadersPolicy
          Compress: true
        Origins:
          - Id: lambda-origin
            DomainName: !Select
              - 2
              - !Split ['/', !GetAtt SlowOriginFunctionUrl.FunctionUrl]
            CustomOriginConfig:
              HTTPSPort: 443
              OriginProtocolPolicy: https-only
              OriginReadTimeout: !Ref OriginReadTimeout
              OriginSSLProtocols:
                - TLSv1.2
            ConnectionAttempts: 1
            ConnectionTimeout: 10
        HttpVersion: http2and3
        PriceClass: PriceClass_200

Outputs:
  DistributionDomainName:
    Value: !GetAtt Distribution.DomainName
  DistributionId:
    Value: !Ref Distribution
  FunctionUrl:
    Value: !GetAtt SlowOriginFunctionUrl.FunctionUrl

Phase 1: Verifying stale-while-revalidate under normal conditions

We set OriginReadTimeout=60s and verified the behavior of stale-while-revalidate under conditions where Lambda's 30-second response returns normally.

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html#stale-while-revalidate

Here are the measurement results over 300 seconds. The initial Miss is excluded as a warm-up.

Phase Period (Age) seq X-Cache Response time Behavior
max-age valid period 22-82 1-7 Hit from cloudfront 30ms Immediate cache return
stale-while-revalidate period 92-112 8-10 Hit from cloudfront 30ms Immediate stale cache return + background revalidation in progress
After revalidation complete - (fresh) 11 Hit from cloudfront 37ms Switched to new timestamp
2nd cycle max-age 10-110 12-22 Hit from cloudfront 25-40ms New cache
2nd cycle revalidation complete - (fresh) 23 Hit from cloudfront 31ms Refreshed again

Even when Age exceeds max-age=90 and becomes Age=92, X-Cache remains Hit from cloudfront and response time stays at 30ms unchanged. This is because stale cache is returned immediately while revalidation happens in the background; the distinction between fresh/stale was confirmed by the body timestamp.

Phase 2: Verifying stale-if-error during timeout

We changed OriginReadTimeout to 20s. Since Lambda responds in 30 seconds, all origin requests time out after 20 seconds and return 504. Starting from a warmed-up cache state, we sent requests for 400 seconds and observed the behavioral transition from the stale-while-revalidate period through the stale-if-error period and beyond stale expiration. Phase classification is based on the Age value returned by CloudFront in responses.

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html#stale-if-error-only

Phase Period (Age) seq X-Cache Response time HTTP Behavior
max-age valid period 16-86 1-8 Hit from cloudfront 23-41ms 200 Immediate cache return
stale-while-revalidate period 96-177 9-17 Hit from cloudfront 25-44ms 200 Immediate stale cache return (background revalidation timing out, no client impact)
stale-if-error period (based on Age at request start) 197-277 18-22 RefreshHit from cloudfront 11-20s 200 Stale cache returned after 20s origin revalidation timeout
stale-if-error exceeded 277+ 23+ Error from cloudfront 20s (first) / 30ms (subsequent) 504 Stale cache unavailable. Returns 504 to client

During the stale-while-revalidate period (seq 9-17), responses are returned immediately in 25-44ms, whereas during the stale-if-error period (seq 18-22), response times jump to 11-20 seconds.

The reason seq 18's response time was approximately 11 seconds is presumed to be that the preceding background revalidation had already joined an in-progress origin connection, so the response returned after only the remaining timeout wait time. From seq 19 onward, a full 20-second wait occurs every request.

The X-Cache header during the stale-if-error period is RefreshHit from cloudfront. Unlike the Hit from cloudfront during the stale-while-revalidate period, this header indicates that stale cache was returned after an attempted origin connection.

After stale-if-error expiration (Age exceeds max-age 90 + stale-if-error 180 = 270), CloudFront returns a 504 error to the client. Since the 504 response is cached for the ErrorCachingMinTTL period (default 10 seconds), subsequent requests return the cached 504 immediately without accessing the origin (response time 30ms).

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html#use-both-stale-directives

Log Correlation (Standard Logging v2)

We correlated the results observed with the curl script against CloudFront's Standard Logging v2 (output to CloudWatch Logs).

Verification item Result
x-edge-location match ✓ All requests NRT12-P9
x-edge-result-type vs X-Cache ✓ RefreshHit/Error exact match
time-taken vs curl response time ✓ 20.011s ≒ curl 20.04s
origin-fbl (Error phase) 20.004s (matches OriginReadTimeout=20s)
origin-fbl during RefreshHit - (unmeasurable due to timeout)

Summary

From this verification, when an origin timeout failure persists, the time during which clients can be protected without any delay (zero-impact protection time) is determined by max-age + stale-while-revalidate. This is because once the stale-if-error period is entered, although stale cache is returned, a synchronous wait of OriginReadTimeout seconds occurs on every request.

DevelopersIO's current primary settings and the zero-impact protection time for each path are as follows.

Path Primary settings Zero-impact protection time (max-age + stale-while-revalidate)
/ Top max-age=60, stale-while-revalidate=120, stale-if-error=900 180s (3 minutes)
/articles/* max-age=300, stale-while-revalidate=450, stale-if-error=900 750s (12.5 minutes)
/author/* /tags/* max-age=120, stale-while-revalidate=300, stale-if-error=600 420s (7 minutes)

The reason the zero-impact protection time for the top page is kept relatively short at 3 minutes is to minimize the reflection lag for new posts, but we are considering extending stale-while-revalidate to better mitigate incident impact.

Share this article

AWSのお困り事はクラスメソッドへ