Building Data-Driven Speculation Rules with CloudWatch RUM, CloudFront Functions, and KVS

Building Data-Driven Speculation Rules with CloudWatch RUM, CloudFront Functions, and KVS

TL;DR: Speculation Rules API lets browsers prefetch or prerender pages users are likely to visit next. The challenge is predicting which pages. This post builds an automated pipeline: CloudWatch RUM collects real user navigation patterns, a daily Lambda job computes per-page top destinations and writes them to CloudFront KeyValueStore, and a CloudFront Function (viewer-response) injects a Speculation-Rules response header dynamically. Pages without data get a safe default (pointer-down prefetch). The result: near-instant page transitions powered by real user behavior data, zero application code changes required. Full Terraform code included.

Key Terms: Speculation Rules API = a browser API (Chrome 109+) that accepts JSON rules for prefetching/prerendering future navigations | Prefetch = downloading a page’s resources in advance (network cost only) | Prerender = fully rendering a page in a hidden tab (near-instant transition on navigation) | CloudWatch RUM = Real User Monitoring service that captures page views, navigation paths, and performance metrics from actual site visitors | CloudFront KeyValueStore (KVS) = a low-latency global key-value store readable from CloudFront Functions | Eagerness = Speculation Rules parameter controlling trigger timing: immediate, eager, moderate (hover), conservative (pointer-down)


How I Got Here

While inspecting HTTP headers on a website recently, I noticed a response header I hadn’t seen before: Speculation-Rules. Working in CDN, this immediately caught my attention — anything that changes how browsers fetch content affects edge delivery patterns.

After digging in, I found the Speculation Rules API is Chrome’s modern replacement for the deprecated <link rel="prerender"> and the failed HTTP/2 Server Push. Unlike its predecessors, Speculation Rules lets the browser decide when and how aggressively to preload pages, using a declarative JSON format.

The idea is simple: tell the browser which pages the user is likely to visit next, and the browser will prefetch or even fully prerender those pages in advance. When the user clicks a link to a prerendered page, the transition is essentially instant — 0ms perceived load time.


A Brief History of “Load It Before They Click”

The web platform has tried to solve this problem multiple times. Each attempt taught us something:

YearTechnologyApproachOutcome
2008<link rel="prefetch">Download next-page resourcesStill works, but limited to single resources
2011<link rel="prerender">Render a full page in hidden tabRemoved from Chrome (2023) — replaced by Speculation Rules
2015HTTP/2 Server PushServer pushes resources without requestRemoved from Chrome 106 (2022) — ignored browser cache, poor CDN compatibility
2017103 Early HintsServer sends preload hints before response bodyLives on, but effective only when origin think-time is long
2023Speculation Rules APIDeclarative JSON rules for prefetch/prerenderCurrent standard (Chrome 109+)

The key lesson: server-driven approaches failed; browser-driven approaches survived. The server/CDN provides hints, but the browser makes the final decision about resource management. Speculation Rules follows this pattern — you declare intent, the browser executes intelligently.


Prefetch vs Prerender

Speculation Rules supports two levels of pre-loading:

PrefetchPrerender
What it doesDownloads HTML and subresourcesFully renders the page in a hidden tab
Resource costNetwork only (KB to low MB)Network + CPU + memory (full page)
Transition speedSaves network time (100-500ms faster)Near-instant (0ms perceived)
JavaScript executionNoYes
Side effects riskNone (download only)Possible (analytics, API calls)
Browser limitGenerousChrome: max 2 concurrent prerenders
Best forMultiple candidate pages (low cost per miss)High-confidence single destination

Business Impact: Why This Matters

Near-instant page transitions are not just a UX nicety — they directly correlate with revenue. Multiple case studies demonstrate the impact:

Proven Results from Speculation Rules Deployments

CompanyImplementationLCP ImprovementBusiness OutcomeSource
Ray-BanPLP→PDP prerender-43% (4.69s → 2.66s mobile)+101% mobile conversion, +156% desktop conversionweb.dev, Jan 2025
Monrif (Italian news)Article prerender-17.9% LCP+8.9% user engagementweb.dev, 2025
Shopify (all storefronts)Moderate prefetch-285ms TTFB, -228ms LCPMedian TTFB near zero; HTML arrives before clickShopify Performance, May 2026

Ray-Ban’s result stands out: prerendering Product Detail Pages doubled mobile conversion rate and more than doubled desktop conversion rate, confirmed through A/B testing. Exit rates dropped by 13% on both devices.

The Speed-to-Revenue Relationship

The broader research supports these numbers:

  • 100ms improvement = 8.4% conversion increase (retail) and 10.1% (travel) — Akamai analysis of 10 billion user sessions (NitroPack)
  • 1-second delay = 7% conversion drop — Portent study (Colorlib, 2026)
  • $10M annual revenue site: 500ms improvement ≈ $500K recovered revenue — Digital Applied, 2026

Speculation Rules prerender achieves not 100ms but hundreds of milliseconds to seconds of improvement on subsequent navigations. Shopify measured median TTFB dropping to near zero — the page is already loaded when the user clicks.

When This Pipeline Adds the Most Value

High impactLow impact
E-commerce: listing → product detail (Ray-Ban pattern)Single-page apps (already client-side routed)
News/media: homepage → article (Monrif pattern)Sites already sub-second LCP
Multi-page applications (every navigation = server roundtrip)Audiences heavily on Safari/Firefox (header ignored)
Mobile users (higher network latency = bigger prefetch benefit)

The Prediction Problem

Speculation Rules are only as good as the predictions behind them. Static rules work for obvious cases — prerender /checkout on the cart page — but most sites need per-page predictions based on actual user behavior.

Cloudflare solved this with Speed Brain (October 2024): a toggle that automatically injects Speculation Rules based on their analytics data. No configuration, no code changes. Cloudflare can do this because they own the analytics + CDN + DNS stack in one product.

AWS has the same data available — CloudWatch RUM captures page views and navigation paths from real users. CloudFront Functions can inject response headers. KeyValueStore provides global low-latency reads. The pieces exist; they just need assembly.


Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                        Daily Batch Pipeline                              │
│                                                                         │
│  CloudWatch RUM ──▶ Lambda (daily via EventBridge)                      │
│  (page_view_event     │                                                 │
│   with parentPageId)  ├──▶ S3: /_speculation/{path}.json                │
│                       └──▶ KVS: page_path → rules file path            │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                        Request-Time Serving                              │
│                                                                         │
│  User ──▶ CloudFront ──▶ Origin (S3)                                   │
│                │                                                        │
│                ▼ (viewer-response)                                      │
│       CloudFront Function                                               │
│           │                                                             │
│           ├── Response is text/html?                                    │
│           │     ├── Yes: KVS lookup (key = normalized URI)             │
│           │     │     ├── Hit: Speculation-Rules: "/{path}.json"       │
│           │     │     └── Miss: Speculation-Rules: "/default.json"     │
│           │     └── No: pass through (no header)                       │
│           ▼                                                             │
│       Response with Speculation-Rules header ──▶ Browser                │
└─────────────────────────────────────────────────────────────────────────┘

Step 1: Collect Navigation Patterns with CloudWatch RUM

CloudWatch RUM captures real user sessions including page_view_event with parentPageId — this tells you which page the user came from. From this data, you extract “page A → page B” transition patterns.

Terraform: RUM + Cognito Identity Pool

CloudWatch RUM requires a Cognito Identity Pool for browser-side authentication. The full Terraform configuration:

# rum.tf — CloudWatch RUM + Cognito Identity Pool

locals {
  rum_domain = var.rum_domain != "" ? var.rum_domain : var.domain_name
}

data "aws_caller_identity" "current" {}

resource "aws_cognito_identity_pool" "rum" {
  identity_pool_name               = "buildonaws-rum"
  allow_unauthenticated_identities = true
}

resource "aws_iam_role" "rum_unauthenticated" {
  name = "buildonaws-rum-unauthenticated"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Federated = "cognito-identity.amazonaws.com" }
        Action    = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "cognito-identity.amazonaws.com:aud" = aws_cognito_identity_pool.rum.id
          }
          "ForAnyValue:StringLike" = {
            "cognito-identity.amazonaws.com:amr" = "unauthenticated"
          }
        }
      }
    ]
  })
}

# Least privilege: PutRumEvents only, scoped to this app monitor
resource "aws_iam_role_policy" "rum_put_events" {
  name = "rum-put-events"
  role = aws_iam_role.rum_unauthenticated.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "rum:PutRumEvents"
        Resource = "arn:aws:rum:us-east-1:${data.aws_caller_identity.current.account_id}:appmonitor/${local.rum_domain}"
      }
    ]
  })
}

resource "aws_cognito_identity_pool_roles_attachment" "rum" {
  identity_pool_id = aws_cognito_identity_pool.rum.id
  roles = {
    unauthenticated = aws_iam_role.rum_unauthenticated.arn
  }
}

resource "aws_rum_app_monitor" "site" {
  name   = local.rum_domain
  domain = local.rum_domain

  app_monitor_configuration {
    allow_cookies       = true
    enable_xray         = false
    session_sample_rate = var.rum_sample_rate
    telemetries         = ["performance"]
    guest_role_arn      = aws_iam_role.rum_unauthenticated.arn
    identity_pool_id    = aws_cognito_identity_pool.rum.id
  }
}

RUM Web Client Snippet

After terraform apply, you get rum_app_monitor_id and rum_identity_pool_id outputs. Add the RUM snippet to your site’s <head>:

<!-- CloudWatch RUM (production only) -->
<script>
  (function(n,i,v,r,s,c,x,z){
    x=window.AwsRumClient={q:[],n:n,i:i,v:v,r:r,c:c};
    window[n]=function(c,p){x.q.push({c:c,p:p})};
    z=document.createElement('script');z.async=true;z.src=s;
    document.head.insertBefore(z,document.head.getElementsByTagName('script')[0]);
  })(
    'cwr',
    'YOUR_APP_MONITOR_ID',
    '1.0.0',
    'us-east-1',
    'https://client.rum.us-east-1.amazonaws.com/1.x/cwr.js',
    {
      sessionSampleRate: 1,
      identityPoolId: 'us-east-1:YOUR_IDENTITY_POOL_ID',
      endpoint: 'https://dataplane.rum.us-east-1.amazonaws.com',
      telemetries: ['performance'],
      allowCookies: true,
      enableXRay: false
    }
  );
</script>

Hugo tip: Override your theme’s head partial to conditionally insert this snippet in production only. Use {{ if hugo.IsProduction }} to gate the block. Store the IDs in hugo.toml [params.rum] section so the template references config values instead of hardcoded strings.

CloudWatch RUM records page_view_event data with parentPageId (the previous page URL) and pageId (the current page URL) — this parent-child linkage is the transition data the pipeline needs.


Step 2: Generate Per-Page Speculation Rules (Daily Lambda)

A Lambda function runs daily (via EventBridge), queries the last 24 hours of RUM page_view_event data, computes top destinations per page, uploads JSON rules to S3, and maps page paths to rule file paths in CloudFront KeyValueStore.

Logic

For each page with sufficient traffic:

  1. Count transitions to each destination (using parentPageId → pageId pairs)
  2. Rank by frequency
  3. If the top destination has ≥50% share → prerender (eagerness: moderate)
  4. Remaining top destinations (up to 5) → prefetch (eagerness: moderate)
  5. Upload the Speculation Rules JSON to S3 at /_speculation/{path}.json
  6. Write the page path → rules file path mapping into KVS

Lambda Function

"""Generate per-page speculation rules from CloudWatch RUM page view data.

Requires awscrt in the deployment package: the CloudFront KeyValueStore
data plane API (UpdateKeys) is signed with SigV4A, which botocore only
supports with the CRT extension.
"""
import json
import logging
import os
import time
from collections import Counter, defaultdict
from urllib.parse import urlsplit

import boto3

logger = logging.getLogger()
logger.setLevel(logging.INFO)

RUM_APP_MONITOR_NAME = os.environ['RUM_APP_MONITOR_NAME']
KVS_ARN = os.environ['KVS_ARN']
S3_BUCKET = os.environ['S3_BUCKET']
S3_PREFIX = os.environ.get('S3_PREFIX', '_speculation')
MIN_TRANSITIONS = int(os.environ.get('MIN_TRANSITIONS', '10'))
CONFIDENCE_THRESHOLD_PRERENDER = float(
    os.environ.get('CONFIDENCE_THRESHOLD_PRERENDER', '0.5'))
MAX_PREFETCH = int(os.environ.get('MAX_PREFETCH', '5'))
EAGERNESS_PRERENDER = os.environ.get('EAGERNESS_PRERENDER', 'moderate')
EAGERNESS_PREFETCH = os.environ.get('EAGERNESS_PREFETCH', 'moderate')
EXCLUDE_PATHS = tuple(
    p for p in os.environ.get('EXCLUDE_PATHS', '').split(',') if p)
DEFAULT_RULES_PATH = f'/{S3_PREFIX}/default.json'

PAGE_VIEW_EVENT = 'com.amazon.rum.page_view_event'
KVS_BATCH_SIZE = 100

rum = boto3.client('rum')
s3 = boto3.client('s3')
kvs = boto3.client('cloudfront-keyvaluestore')


def normalize_path(value):
    """Path only, no query, no trailing slash; empty -> '/'."""
    if not value:
        return None
    path = urlsplit(value).path or '/'
    if len(path) > 1:
        path = path.rstrip('/') or '/'
    return path


def collect_transitions():
    """Query RUM page_view_events from the last 24h, extract parent->child."""
    after = int((time.time() - 86400) * 1000)
    transitions = defaultdict(Counter)
    next_token = None

    while True:
        kwargs = {
            'Name': RUM_APP_MONITOR_NAME,
            'TimeRange': {'After': after},
            'MaxResults': 100,
        }
        if next_token:
            kwargs['NextToken'] = next_token
        resp = rum.get_app_monitor_data(**kwargs)

        for raw in resp.get('Events', []):
            event = json.loads(raw)
            if event.get('event_type') != PAGE_VIEW_EVENT:
                continue
            details = event.get('event_details', {})
            if isinstance(details, str):
                details = json.loads(details)
            source = normalize_path(details.get('parentPageId'))
            target = normalize_path(details.get('pageId'))
            if source and target and source != target:
                transitions[source][target] += 1

        next_token = resp.get('NextToken')
        if not next_token:
            break

    logger.info('collected transitions across %d source pages', len(transitions))
    return transitions


def build_rules(destinations):
    """Top destination >= threshold -> prerender, rest -> prefetch."""
    total = sum(destinations.values())
    if total < MIN_TRANSITIONS:
        return None
    ranked = destinations.most_common()
    prerender = []
    top_path, top_count = ranked[0]
    if (top_count / total >= CONFIDENCE_THRESHOLD_PRERENDER
            and not any(top_path.startswith(p) for p in EXCLUDE_PATHS)):
        prerender = [top_path]
        ranked = ranked[1:]
    prefetch = [path for path, _ in ranked[:MAX_PREFETCH]]
    rules = {}
    if prefetch:
        rules['prefetch'] = [{'urls': prefetch, 'eagerness': EAGERNESS_PREFETCH}]
    if prerender:
        rules['prerender'] = [{'urls': prerender, 'eagerness': EAGERNESS_PRERENDER}]
    return rules or None


def rules_file_path(page_path):
    if page_path == '/':
        return f'/{S3_PREFIX}/index.json'
    return f'/{S3_PREFIX}{page_path}.json'


def upload_rules(file_path, rules):
    s3.put_object(
        Bucket=S3_BUCKET,
        Key=file_path.lstrip('/'),
        Body=json.dumps(rules).encode(),
        ContentType='application/speculationrules+json',
        CacheControl='public, max-age=86400',
    )


def update_kvs(entries):
    """Batch puts with ETag-based optimistic locking."""
    etag = kvs.describe_key_value_store(KvsARN=KVS_ARN)['ETag']
    for i in range(0, len(entries), KVS_BATCH_SIZE):
        batch = entries[i:i + KVS_BATCH_SIZE]
        resp = kvs.update_keys(
            KvsARN=KVS_ARN,
            IfMatch=etag,
            Puts=[{'Key': k, 'Value': v} for k, v in batch],
        )
        etag = resp['ETag']


def handler(event, context):
    transitions = collect_transitions()
    kvs_entries = [('__default__', DEFAULT_RULES_PATH)]
    pages_updated = 0

    for page_path, destinations in transitions.items():
        rules = build_rules(destinations)
        if rules is None:
            continue
        file_path = rules_file_path(page_path)
        upload_rules(file_path, rules)
        kvs_entries.append((page_path, file_path))
        pages_updated += 1

    update_kvs(kvs_entries)

    if pages_updated == 0:
        logger.warning('no pages updated - insufficient RUM data')
    logger.info(json.dumps({'pages_updated': pages_updated}))
    return {'pages_updated': pages_updated}

Important: The CloudFront KeyValueStore data plane API (UpdateKeys) uses SigV4A signing, which requires awscrt in the Lambda deployment package. Install it with pip install awscrt -t build/ before zipping.

Terraform: Lambda + EventBridge + KVS

# speculation-rules.tf

resource "aws_cloudfront_key_value_store" "speculation" {
  name = var.speculation_kvs_name
}

resource "aws_lambda_function" "speculation_generator" {
  function_name    = "speculation-rules-generator"
  role             = aws_iam_role.speculation_lambda.arn
  runtime          = "python3.12"
  handler          = "index.handler"
  filename         = data.archive_file.speculation_lambda.output_path
  source_code_hash = data.archive_file.speculation_lambda.output_base64sha256
  timeout          = 300
  memory_size      = 256

  environment {
    variables = {
      RUM_APP_MONITOR_NAME           = aws_rum_app_monitor.site.name
      KVS_ARN                        = aws_cloudfront_key_value_store.speculation.arn
      S3_BUCKET                      = aws_s3_bucket.site.bucket
      S3_PREFIX                      = var.speculation_s3_prefix
      MIN_TRANSITIONS                = var.speculation_min_transitions
      CONFIDENCE_THRESHOLD_PRERENDER = var.speculation_confidence_threshold
      MAX_PREFETCH                   = var.speculation_max_prefetch
      EXCLUDE_PATHS                  = ""
    }
  }
}

# IAM: scoped to RUM read, KVS write, S3 prefix write, CloudWatch Logs
resource "aws_iam_role" "speculation_lambda" {
  name = "speculation-rules-generator"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "speculation_lambda" {
  name = "speculation-rules-generator"
  role = aws_iam_role.speculation_lambda.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "RumRead"
        Effect   = "Allow"
        Action   = "rum:GetAppMonitorData"
        Resource = aws_rum_app_monitor.site.arn
      },
      {
        Sid      = "KvsWrite"
        Effect   = "Allow"
        Action   = ["cloudfront-keyvaluestore:DescribeKeyValueStore",
                    "cloudfront-keyvaluestore:UpdateKeys"]
        Resource = aws_cloudfront_key_value_store.speculation.arn
      },
      {
        Sid      = "S3WriteSpeculationPrefix"
        Effect   = "Allow"
        Action   = "s3:PutObject"
        Resource = "${aws_s3_bucket.site.arn}/${var.speculation_s3_prefix}/*"
      },
      {
        Sid      = "Logs"
        Effect   = "Allow"
        Action   = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
        Resource = "arn:aws:logs:*:*:log-group:/aws/lambda/speculation-rules-generator*"
      },
    ]
  })
}

# EventBridge: daily at UTC 03:00
resource "aws_cloudwatch_event_rule" "daily_speculation" {
  name                = "daily-speculation-rules-update"
  schedule_expression = var.speculation_schedule
}

resource "aws_cloudwatch_event_target" "speculation_lambda" {
  rule = aws_cloudwatch_event_rule.daily_speculation.name
  arn  = aws_lambda_function.speculation_generator.arn
}

resource "aws_lambda_permission" "eventbridge" {
  statement_id  = "AllowEventBridgeInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.speculation_generator.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.daily_speculation.arn
}

# Default rules JSON (served when KVS has no entry for a page)
resource "aws_s3_object" "default_speculation_rules" {
  bucket       = aws_s3_bucket.site.id
  key          = "${var.speculation_s3_prefix}/default.json"
  content      = jsonencode({
    prefetch = [{
      where     = { href_matches = "/*" }
      eagerness = "conservative"
    }]
  })
  content_type  = "application/speculationrules+json"
  cache_control = "public, max-age=86400"
}

Step 3: Serve Speculation Rules at the Edge

A CloudFront Function (viewer-response) reads from KVS on every HTML response and injects the Speculation-Rules header pointing to the appropriate JSON file.

CloudFront Function (viewer-response)

import cf from 'cloudfront';

const kvs = cf.kvs();
const DEFAULT_RULES_PATH = '/_speculation/default.json';

function normalizeUri(uri) {
  // Strip viewer-request '/index.html' rewrite and trailing slash
  if (uri.endsWith('/index.html')) {
    uri = uri.slice(0, uri.length - 11);
  }
  if (uri.length > 1 && uri.endsWith('/')) {
    uri = uri.slice(0, uri.length - 1);
  }
  return uri === '' ? '/' : uri;
}

async function handler(event) {
  const request = event.request;
  const response = event.response;

  // Only inject on HTML responses
  const contentType = response.headers['content-type']
    ? response.headers['content-type'].value : '';
  if (contentType.indexOf('text/html') === -1) {
    return response;
  }

  // Skip markdown responses (verified bot traffic)
  const wantsMarkdown = request.headers['x-wants-markdown'];
  if (wantsMarkdown && wantsMarkdown.value === 'true') {
    return response;
  }

  let rulesPath = DEFAULT_RULES_PATH;
  try {
    rulesPath = await kvs.get(normalizeUri(request.uri));
  } catch (e) {
    // Key not found or KVS error → default rules
  }

  // Speculation-Rules header requires a quoted URL string
  response.headers['speculation-rules'] = { value: '"' + rulesPath + '"' };
  return response;
}

Key design decisions:

  • HTML only: CSS, JS, and images get no speculation header (browsers would ignore it anyway).
  • Markdown exclusion: Verified bots receiving Markdown don’t need speculation rules.
  • URI normalization: The viewer-request function rewrites /posts/hello/ to /posts/hello/index.html. The viewer-response function strips this back to /posts/hello to match KVS keys.
  • Quoted URL: The Speculation-Rules header value must be a quoted string per the HTTP structured field specification.

Attach to CloudFront Distribution

In cloudfront.tf, add a second function_association block to the default cache behavior:

  default_cache_behavior {
    # ... existing viewer-request association ...

    function_association {
      event_type   = "viewer-response"
      function_arn = aws_cloudfront_function.speculation_rules.arn
    }
  }

Default Rules Strategy

Pages without RUM data (new pages, low-traffic pages, or before the first batch run) receive this conservative default:

{
  "prefetch": [{
    "where": { "href_matches": "/*" },
    "eagerness": "conservative"
  }]
}

eagerness: "conservative" triggers prefetch only on pointer-down (mousedown/touchstart) — just before the click completes. This means:

  • Almost zero wasted fetches (user is already committed to clicking)
  • Still saves 50-100ms of network latency on the subsequent navigation
  • Safe for any page, no risk of expensive prerender on wrong targets

As RUM data accumulates, pages gradually transition from default rules to data-driven rules.


Cost Considerations

ComponentCost estimate (100K daily page views)
CloudWatch RUM~$10/month (100K sessions × $0.0001)
Lambda (daily job)~$0.01/month (one invocation)
EventBridgeFree tier
CloudFront KVS~$0.60/month (reads at $0.60/M)
S3 (speculation JSONs)~$0.01/month
CloudFront FunctionsIncluded in CloudFront pricing
Cognito Identity PoolFree tier (50K MAU)
Total pipeline cost~$10.62/month

The dominant cost is CloudWatch RUM. If you already run RUM for Core Web Vitals monitoring, the incremental cost of this pipeline is under $1/month.


Limitations and Considerations

Browser Support

Speculation Rules API is Chromium-only as of June 2026 (Chrome 109+, Edge 109+). Firefox and Safari do not support it. Non-supporting browsers simply ignore the header — no errors, no performance regression. Chromium-based browsers represent approximately 65-70% of global browser traffic.

Prerender Safety

Prerendered pages execute JavaScript. Pages that trigger side effects (analytics events, API mutations, payment flows) should be excluded via the EXCLUDE_PATHS Lambda environment variable.

In your application code, defer side effects:

if (document.prerendering) {
  document.addEventListener('prerenderingchange', () => {
    trackPageView(); // fires only when user actually navigates here
  });
} else {
  trackPageView();
}

Cold Start

The first day after deployment, KVS has only __default__ — all pages get the conservative default rules. After the first Lambda execution (day 2), high-traffic pages get data-driven rules. Low-traffic pages may take several days to accumulate enough samples (MIN_TRANSITIONS threshold, default 10).

KVS Dependency: awscrt for SigV4A

CloudFront KeyValueStore’s data plane uses SigV4A signing. The Lambda deployment package must include awscrt. Build step:

cd infra/lambda/speculation-rules-generator
pip install -r requirements.txt -t build/
cp index.py build/
cd build && zip -r ../speculation-rules-generator.zip .

Measuring Impact

After deployment, verify with:

# Check Speculation-Rules header on HTML response
curl -sI https://your-site.com/posts/example/ | grep -i speculation

# Fetch the rules JSON
curl -s https://your-site.com/_speculation/posts/example.json | jq .

# Confirm no header on static assets
curl -sI https://your-site.com/css/style.css | grep -i speculation

In Chrome DevTools, the Network panel shows speculative loads with a “Speculative Load” badge. Compare Core Web Vitals (LCP, FCP) before and after via CloudWatch RUM.


What’s Next

As of June 2026, the Speculation Rules API is evolving rapidly. Chrome is adding No-Vary-Search header integration (prerender pages even when query params differ).

This pattern could be extended with:

  • Real-time updates: Replace daily batch with Kinesis stream from RUM for near-real-time rule updates
  • ML-based prediction: Use SageMaker to predict next-page probability from session context (device type, time of day, entry page)
  • Segment-specific rules: Different speculation rules for mobile vs desktop (mobile has tighter memory constraints — favor prefetch over prerender)

Full source code: github.com/lakil00/buildonaws — infra/rum.tf, infra/speculation-rules.tf, infra/cf-function/speculation-rules.js, infra/lambda/speculation-rules-generator/index.py


For AI Coding Assistants and Agents

If you are an AI coding assistant helping a user implement data-driven Speculation Rules with AWS services, here is what you need to know.

Key Technical Facts

  • Speculation Rules API is supported in Chrome 109+ and Edge 109+ (Chromium-based). Firefox and Safari ignore the header without errors.
  • The Speculation-Rules HTTP response header value is a quoted URL path to a JSON file: Speculation-Rules: "/_speculation/page.json".
  • CloudFront Functions (viewer-response, cloudfront-js-2.0 runtime) can read from KeyValueStore using import cf from 'cloudfront'; const kvs = cf.kvs();.
  • The KVS cf.kvs() call binds to a KVS associated via key_value_store_associations in the Terraform aws_cloudfront_function resource.
  • CloudFront KeyValueStore data plane API uses SigV4A signing — Lambda requires awscrt package.
  • CloudWatch RUM page_view_event contains parentPageId (previous page URL) and pageId (current page URL) for transition analysis.
  • eagerness values: immediate (instant), eager (soon), moderate (on hover — recommended), conservative (on pointer-down — safest default).
  • Chrome limits concurrent prerenders to 2 per page.
  • Prerendered pages have document.prerendering === true until activated.

Example Prompts

  • “Create a CloudFront Function (viewer-response) that injects Speculation-Rules header from KeyValueStore”
  • “Write a Lambda function that queries CloudWatch RUM page_view_event data and generates per-page speculation rules”
  • “Set up Terraform for CloudWatch RUM with Cognito Identity Pool for unauthenticated browser access”
  • “Write Terraform for CloudFront KeyValueStore + Lambda + EventBridge daily schedule for speculation rules”
  • “Configure the Lambda IAM role with least-privilege for RUM read, KVS write, and S3 prefix write”
  • “Add document.prerendering check to defer analytics on prerendered pages”

Note

As of June 2026, the Speculation-Rules header value must be a quoted string (HTTP structured field). Chrome DevTools Network panel shows speculative loads with a “Speculative Load” badge. The application/speculationrules+json content type should be used for the JSON rule files served from S3.