Edge Caching
Marketing pages on the WarmlyYours site are served from Cloudflare's edge
(the www-edge Worker plus zone cache rules). For Cloudflare to cache a
response, that response must not carry a per-visitor Set-Cookie header —
a cached page is shared by every visitor, so it cannot also be stamped
with one visitor's session cookie. Reconciling edge caching with Rails'
guest-session machinery is what the edge_cached declaration exists to do.
The ghost-guest concern
Two before_actions collide on a cacheable request:
init_current_user(inControllers::Authenticable) mints a guest
Customer(aPartywithstate = 'guest') for any anonymous visitor
and records its id insession[:guest_user_id].set_cloudflare_cache(inControllers::CloudflareCaching) calls
skip_session, which setsrequest.session_options[:skip] = trueso
Rails omits theSet-Cookie: _hwsessionheader and the page stays
cacheable.
If init_current_user runs and then skip_session discards the session
write, the freshly created guest is never persisted to a cookie. The
visitor's next request finds no session, mints another guest, and the
cycle repeats — producing orphaned guest records with no continuity
between requests ("ghost guests"). A controller that calls
set_cloudflare_cache on an action where init_current_user still runs
leaks one guest row per uncached-but-guest-minting hit.
The fix is to skip init_current_user on edge-cached actions, so no guest
is minted on a request whose session is about to be discarded anyway.
edge_cached
edge_cached is a controller class macro (in
app/concerns/controllers/cloudflare_caching.rb) that makes the
skip explicit and self-documenting. It:
- Calls
skip_before_action :init_current_userfor the named actions, so
no guest is minted on those requests. - Records the edge-cached action set on the controller
(_edge_cached_actions) so the runtime guard can check it.
class Www::ProductsController < BasePortalController
edge_cached only: %i[index line code reviews section]
def index
# ... load data ...
set_cloudflare_cache(time_in_secs: 4.hours.to_i, tags: %w[sale product])
end
end
For a controller whose every action is edge-cached, call it bare:
class PostsController < ApplicationController
edge_cached
# ...
end
Controllers that currently declare edge_cached include PagesController,
PostsController, VideoMediaController, and the Www::* page
controllers (Products, TowelWarmers, SupportPortals,
FloorPlanDisplays, RoomPlans, Sitemap, Showcases, Publications,
Reviews, InfraredHeatingPanels, Catalog#resolve).
The runtime guard
set_cloudflare_cache calls warn_if_not_edge_cached, which enforces the
pairing so the two declarations can't drift apart:
- Development / test (
Rails.env.local?): raises aRuntimeError
immediately, so a controller that caches without declaringedge_cached
fails before it can ship. - Production: reports the mismatch to AppSignal via
ErrorReporting.warningunder a[CloudflareCaching]message, so a leak
introduced in production surfaces as an alert rather than silent guest
proliferation.
A page can also opt out of caching at render time with skip_edge_cache!
(sets @_skip_edge_cache), which any helper that produced per-user content
during the request can call — e.g. an inline lead form prefilled with the
context user's data. set_cloudflare_cache honors that flag even when it
runs from an after_action. The "lazy modal" lead-form pattern is
unaffected, because its form is fetched per-request from an uncached
endpoint, so the host page stays cacheable.
A separate prevent_edge_cache_on_error after_action is the final safety
net: any non-2xx response has its Cloudflare cache headers forced to
no-store, so error pages are never edge-cached even if
reset_cloudflare_cache wasn't called explicitly.
Where guest sessions live
Guest sessions are stored in Redis/Valkey, not in the cookie — the
_hwsession cookie only carries the session id. The session store is a
dedicated RedisCacheStore (config/initializers/200_session_store.rb,
namespace session, 7-day TTL) pointed at logical Redis DB
REDIS_DB_SESSIONS (0). Under the three-flavor Valkey split, that DB maps
to the sessions flavor — the heatwave-valkey-sessions accessory,
which runs noeviction so session keys are never evicted under memory
pressure (see RedisConfig and
doc/tasks/202606111858_VALKEY_THREE_FLAVOR_SPLIT.md). Edge-cached
responses deliberately skip writing the session cookie; the guest mechanism
only engages on the uncached, per-visitor request paths.
Verify
When adding or auditing an edge-cached controller:
-
A controller that calls
set_cloudflare_cachemust also declare
edge_cachedfor that action — otherwise it raises in dev/test. The
guard makes this hard to get wrong, but watch AppSignal for any
[CloudflareCaching]web_warningincidents, which mean a production
path is caching without the declaration. -
Confirm guest creation tracks visitor activity rather than spiking.
Guests arestate = 'guest'parties:SELECT DATE(created_at) AS day, COUNT(*) AS guests_created FROM parties WHERE state = 'guest' AND created_at >= NOW() - INTERVAL '30 days' GROUP BY DATE(created_at) ORDER BY day DESC;A sustained, large gap between guest creation and real visit volume is
the signature of an edge-cached action that still mints guests.
Turbo Stream Edge Caching
Overview
Current Strategy: Bypass Cache for All Turbo Streams
Turbo Stream requests are not cached at Cloudflare's edge. This is simpler and safer than trying to cache some turbo streams while bypassing others.
Why Not Cache Turbo Streams?
- Simplicity - No need to differentiate between cacheable and non-cacheable turbo streams
- Safety - Avoids bugs where search/filter results get incorrectly cached
- Minimal Impact - Turbo stream responses are typically small and fast from origin
- Dynamic Content - Many turbo streams contain user-specific or frequently-changing data
Cloudflare Configuration
Cache Rule
Rule Name: Bypass Cache Turbo Stream Request
Expression:
(any(http.request.headers["accept"][*] contains "turbo-stream") and http.host eq "www.warmlyyours.com")
Action: Bypass cache
This matches all requests where:
- The
Acceptheader containsturbo-stream(indicating a Turbo Stream request) - The host is
www.warmlyyours.com
Rule Order
The bypass rules are evaluated before the general caching rules:
1. Bypass Cache CRM and API
2. Bypass Cache assets-manifest.json
3. Bypass cache global
4. Bypass cache for search/filter requests (q%5B) ← Search bypassed here
5. Bypass Cache Turbo Stream Request ← Turbo streams bypassed here
6. Cache Localized pages on www non paginated ← Only regular pages cached
7. Cache Paginated WWW pages including query string
Request Types
| Type | Accept Header | Cached? |
|---|---|---|
| Regular HTML | text/html |
✅ Yes (via rule 6/7) |
| Turbo Drive | text/html |
✅ Yes (looks like regular HTML) |
| Turbo Frame | text/html + Turbo-Frame header |
✅ Yes |
| Turbo Stream | text/vnd.turbo-stream.html |
❌ No (bypassed) |
Previous Approach (Deprecated)
Previously, we attempted to cache some Turbo Stream responses using a _ts=1 query parameter to differentiate cache keys. This was removed because:
- It added complexity to determine which turbo streams to cache
- Search/filter turbo streams were incorrectly getting cached
- The performance benefit was marginal compared to the debugging headache
The JavaScript that appended _ts=1 to turbo stream requests has been removed from client/js/www/setup/turbo.js.
Related Files
client/js/www/setup/turbo.js- Turbo setup (no longer modifies requests)app/controllers/application_controller.rb-set_cloudflare_cacheandreset_cloudflare_cachedata/cloudflare_rules/production/latest/rulesets.yml- Exported Cloudflare rules
Cache Invalidation
When content changes, purge the cache by tags:
# Purge all video-related caches
Cache::EdgeCacheUtility.instance.('video')
# Purge product-related caches
Cache::EdgeCacheUtility.instance.('product')