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 (in Controllers::Authenticable) mints a guest
    Customer (a Party with state = 'guest') for any anonymous visitor
    and records its id in session[:guest_user_id].
  • set_cloudflare_cache (in Controllers::CloudflareCaching) calls
    skip_session, which sets request.session_options[:skip] = true so
    Rails omits the Set-Cookie: _hwsession header 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:

  1. Calls skip_before_action :init_current_user for the named actions, so
    no guest is minted on those requests.
  2. 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 a RuntimeError
    immediately, so a controller that caches without declaring edge_cached
    fails before it can ship.
  • Production: reports the mismatch to AppSignal via
    ErrorReporting.warning under 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_cache must also declare
    edge_cached for that action — otherwise it raises in dev/test. The
    guard makes this hard to get wrong, but watch AppSignal for any
    [CloudflareCaching] web_warning incidents, which mean a production
    path is caching without the declaration.

  • Confirm guest creation tracks visitor activity rather than spiking.
    Guests are state = '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?

  1. Simplicity - No need to differentiate between cacheable and non-cacheable turbo streams
  2. Safety - Avoids bugs where search/filter results get incorrectly cached
  3. Minimal Impact - Turbo stream responses are typically small and fast from origin
  4. 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:

  1. The Accept header contains turbo-stream (indicating a Turbo Stream request)
  2. 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:

  1. It added complexity to determine which turbo streams to cache
  2. Search/filter turbo streams were incorrectly getting cached
  3. 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_cache and reset_cloudflare_cache
  • data/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.purge_by_tags('video')

# Purge product-related caches
Cache::EdgeCacheUtility.instance.purge_by_tags('product')

References