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:
| Year | Technology | Approach | Outcome |
|---|---|---|---|
| 2008 | <link rel="prefetch"> | Download next-page resources | Still works, but limited to single resources |
| 2011 | <link rel="prerender"> | Render a full page in hidden tab | Removed from Chrome (2023) — replaced by Speculation Rules |
| 2015 | HTTP/2 Server Push | Server pushes resources without request | Removed from Chrome 106 (2022) — ignored browser cache, poor CDN compatibility |
| 2017 | 103 Early Hints | Server sends preload hints before response body | Lives on, but effective only when origin think-time is long |
| 2023 | Speculation Rules API | Declarative JSON rules for prefetch/prerender | Current 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:
| Prefetch | Prerender | |
|---|---|---|
| What it does | Downloads HTML and subresources | Fully renders the page in a hidden tab |
| Resource cost | Network only (KB to low MB) | Network + CPU + memory (full page) |
| Transition speed | Saves network time (100-500ms faster) | Near-instant (0ms perceived) |
| JavaScript execution | No | Yes |
| Side effects risk | None (download only) | Possible (analytics, API calls) |
| Browser limit | Generous | Chrome: max 2 concurrent prerenders |
| Best for | Multiple 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
| Company | Implementation | LCP Improvement | Business Outcome | Source |
|---|---|---|---|---|
| Ray-Ban | PLP→PDP prerender | -43% (4.69s → 2.66s mobile) | +101% mobile conversion, +156% desktop conversion | web.dev, Jan 2025 |
| Monrif (Italian news) | Article prerender | -17.9% LCP | +8.9% user engagement | web.dev, 2025 |
| Shopify (all storefronts) | Moderate prefetch | -285ms TTFB, -228ms LCP | Median TTFB near zero; HTML arrives before click | Shopify 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 impact | Low 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 inhugo.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:
- Count transitions to each destination (using
parentPageId→pageIdpairs) - Rank by frequency
- If the top destination has ≥50% share → prerender (
eagerness: moderate) - Remaining top destinations (up to 5) → prefetch (
eagerness: moderate) - Upload the Speculation Rules JSON to S3 at
/_speculation/{path}.json - 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 requiresawscrtin the Lambda deployment package. Install it withpip 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/helloto 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
| Component | Cost estimate (100K daily page views) |
|---|---|
| CloudWatch RUM | ~$10/month (100K sessions × $0.0001) |
| Lambda (daily job) | ~$0.01/month (one invocation) |
| EventBridge | Free tier |
| CloudFront KVS | ~$0.60/month (reads at $0.60/M) |
| S3 (speculation JSONs) | ~$0.01/month |
| CloudFront Functions | Included in CloudFront pricing |
| Cognito Identity Pool | Free 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-RulesHTTP response header value is a quoted URL path to a JSON file:Speculation-Rules: "/_speculation/page.json". - CloudFront Functions (viewer-response,
cloudfront-js-2.0runtime) can read from KeyValueStore usingimport cf from 'cloudfront'; const kvs = cf.kvs();. - The KVS
cf.kvs()call binds to a KVS associated viakey_value_store_associationsin the Terraformaws_cloudfront_functionresource. - CloudFront KeyValueStore data plane API uses SigV4A signing — Lambda requires
awscrtpackage. - CloudWatch RUM
page_view_eventcontainsparentPageId(previous page URL) andpageId(current page URL) for transition analysis. eagernessvalues: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 === trueuntil 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.