Drei PRs, eine Ursache: Der HTML-Body variiert mit Login-Status, UA und Flash — Eigenschaften, von denen der Cache-Key des CDN nichts weiß —, sodass Inhalte zwischen Nutzern lecken. Claude die PR-Kette lesen zu lassen liefert drei wiederverwendbare Muster: Lazy-Personalize-Frames, clientseitiges Verstecken via CSS und Cache-Grenz-Wächter.
Nutzer öffneten meine Seite und unten auf jeder öffentlichen Seite schwebte eine Zeile "Content missing". Die ersten Meldungen kamen alle von iOS — mein erster Reflex: irgendein seltsames Verhalten des Hotwire-Native-Clients. Erst nach dem Durchwühlen der Cloudflare-Logs wurde mir klar: Damit hat der Client nichts zu tun. Der CDN-Cache ist vergiftet, und alle drei Plattformen (Web / iOS / Android) können hineinlaufen — iOS hat es nur als Erstes ans Licht gebracht.
Reflex eins: ein Hotwire-Native-Bug. Reflex zwei: irgendein bizarres Edge-Verhalten von Cloudflare. Beides falsch. Selbst diese Bug-Klasse als „CDN-Problem" zu bezeichnen ist falsch — das CDN tut genau das, was du ihm gesagt hast.
Als ich Claude die PR-Kette der Reihe nach lesen ließ, hatten drei aufeinanderfolgende PRs alle dieselbe Ursache: Der HTML-Body variiert mit Login-Status, UA und Flash — Eigenschaften des Requests, von denen der Cache-Key des CDN nichts weiß. Drei verschiedene Symptome, eine einzige Ursache.
/, /topics, /square, /coins, /searches/app werden über public_expires_in im CDN gecacht. Im Layout stand:
<% if mobile_hotwire? && user_signed_in? %>
<%= render "shared/tab_badge" %>
<% end %>
shared/tab_badge rendert ein Turbo-Frame:
<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>
Die Absicht ist klar: eingeloggte Hotwire-Native-Nutzer sehen einen Punkt für ungelesene Benachrichtigungen in der unteren Tab-Bar. Web braucht das nicht (eigener Render-Pfad), und nicht eingeloggte Nutzer ebenfalls nicht.
Das Problem: Der Branch mobile_hotwire? && user_signed_in? macht aus derselben URL zwei verschiedene HTML-Varianten. Aber der Cache-Key des CDN sieht nur Dinge wie URL und Accept-Header — er hat keine Ahnung, ob du eingeloggt bist.
Zeitlinie:
/ auf. Das CDN geht zum Origin und cached das HTML mit dem tab-badge-Frame./ auf. Das CDN trifft denselben Cache und liefert ihm dasselbe HTML.src /notifications?badge_only=true auf.NotificationsController hat ein before_action :authenticate_user!, sieht keine Session und 302t auf /users/sign_in.tab_badge und schreibt "Content missing" hin.Nicht-eingeloggte Besucher aller drei Plattformen (Web / iOS / Android) können dies treffen — sobald irgendein Cache-Slot zuerst von einem eingeloggten Nutzer befüllt wurde. Die Meldungen häuften sich auf iOS, weil iOS-Nutzer die höchste Login-Rate haben — die Caches wurden also am häufigsten von eingeloggtem Zustand vergiftet, und nicht eingeloggte iOS-Besucher hatten die höchste Wahrscheinlichkeit, auf einen vergifteten Eintrag zu treffen. Web und Android waren nicht „in Ordnung", sondern hatten niedrigere Trigger-Raten und weniger Reports — eine im Kern site-weite Katastrophe wurde durch frühe Daten als „iOS-spezifisch" verkleidet.
Der Fix verläuft in zwei Schritten, beide darauf ausgelegt, den Cache nicht mehr nach Login-Status aufzuspalten:
Schritt 1: Das && user_signed_in? muss aus dem Layout raus. Sonst spielst du ewig Cache-Key-Roulette:
<%# An alle mobile_hotwire?-Requests rendern, unabhängig vom Login —
der user_signed_in?-Branch teilt das HTML derselben URL in zwei
Varianten, und die jeweils gewinnende Variante wird im CDN-Slot
an den nächsten Besucher ausgeliefert. %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
Schritt 2: Der Frame-Endpunkt /notifications?badge_only=true muss bei nicht eingeloggten Requests ein strukturell identisches 200-Frame zurückgeben, kein 302. Gleichzeitig muss der Endpunkt selbst private, no-store sein, damit das CDN die ungelesene Anzahl eines Nutzers nicht mit anderen teilt.
class NotificationsController < ApplicationController
before_action :authenticate_user!, except: [:index]
def index
if params[:badge_only] || params[:count_only]
response.headers["Cache-Control"] = "private, no-store"
redirect_to notifications_path and return unless turbo_frame_request?
@unread_count = user_signed_in? ? current_user.notifications.unread.count : 0
if params[:badge_only]
render(partial: "notifications/tab_badge")
else
render(partial: "notifications/bell")
end
return
end
authenticate_user!
# ... normale index-Logik
end
end
authenticate_user! wird nur für index umgangen — und auch nur für Frame-Sub-Requests; Nicht-Frame-Requests durchlaufen weiterhin den Redirect.
Im selben PR steckte ein formgleicher Bug: nicht eingeloggte Hotwire-Native-Nutzer sahen auch das Compose-FAB (schwebenden Button) — dieselbe Vergiftung, ein durch Login-Zustand verseuchter Cache leckte an nicht eingeloggte Besucher. Ursprünglich entschied das Layout direkt per user_signed_in?, ob das Stimulus-Controller-Div gerendert wird. Fix: das FAB ebenfalls in ein Lazy-Frame verschieben, gerendert vom private, no-store-Endpunkt /personalize/compose_fab.
<%# Das Layout gibt immer ein leeres Frame aus %>
<% if mobile_hotwire? %>
<turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# /personalize/compose_fab Template %>
<%= turbo_frame_tag "compose-fab" do %>
<% if user_signed_in? %>
<div data-controller="bridge--compose-fab" class="hidden"></div>
<% end %>
<% end %>
Das Layout gibt jedes Mal ein leeres Frame aus; der Endpunkt im Frame entscheidet je nach Login-Status, ob das Controller-Div eingefügt wird. Im HTML der Hauptseite gibt es keinerlei nutzerabhängiges Markup — egal wie das CDN cached, nichts geht kaputt.
Tage nach dem Release von #117 ein weiterer Report: ein nicht eingeloggter Nutzer rief die Startseite auf und sah oben einen "Erfolgreich angemeldet"-Flash-Banner — verstand nichts.
Dieselbe Krankheit, ein neues Symptom.
Im Layout:
<%= render "shared/_notice" %>
Diese Zeile rendert flash[:notice] / flash[:alert]. Der erste Request nach jedem Redirect trägt Flash mit sich, z. B.:
redirect_to root_path nach erfolgreichem Loginredirect_to root_path nach Logoutredirect_back(fallback_location: ...)Wenn die Redirect-Ziel-URL ein public_expires_in-Pfad ist, wird die Flash in das gecachte HTML eingebrannt. Der nächste anonyme Besucher derselben URL erhält dasselbe HTML — und sieht die "Erfolgreich angemeldet"-Meldung eines anderen.
Die naheliegende Versuchung ist, jede cachebare View umzubauen und das Flash-Rendering ins Frame zu verlegen. Die robustere Lösung ist, an der Cache-Grenze zu blocken: Wenn diese Response Flash trägt, darf sie nicht öffentlich gecacht werden.
def public_expires_in(duration)
return unless Rails.env.live?
# Flash wird direkt im Layout gerendert; diese Response zu cachen
# würde die Notice des vorigen Nutzers an den nächsten anonymen
# Besucher leaken
if flash.any?
response.headers["Cache-Control"] = "private, no-store"
else
expires_in duration, public: true
end
end
Vorteile dieses Ansatzes:
public_expires_in nutzen, profitieren automatisch.Bei der Gelegenheit fiel mir auf, dass coins#show selbst expires_in 5.minutes, public: true gesetzt hatte und damit den Helper umging. Auf public_expires_in umgestellt — sonst würde derselbe Flash-Leak-Bug von dort wieder auftauchen.
Nach dem Deploy muss cloudflare:purge_personalized_pages einmal laufen — bereits vergiftete Cache-Einträge laufen nicht automatisch ab und bleiben bis zum Ende ihrer TTL hängen (/topics ist 1 Woche).
/topics nichtWenige Stunden später, drittes Symptom: eingeloggte Nutzer auf /topics sahen den „+ Neues Topic"-Button nicht. Reload half nicht. Aber derselbe Nutzer, der von einer anderen Seite zu /topics?r=1 (zufälliger Parameter zum Cache-Bypass) ging, bekam den Button zurück.
/topics läuft mit public_expires_in 1.week — der aggressivste Cache. Die ursprüngliche View:
<% unless mobile_hotwire? %>
<a href="<%= new_topic_path %>" class="btn-new-topic">+ Create</a>
<% end %>
<% if mobile_hotwire? %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
Zwei Wächter: ein UA-Wächter (Web vs. Native) und ein Auth-Wächter (eingeloggt oder nicht). Das CDN kennt keinen davon — wer als Erster zum Origin durchschlägt, bestimmt die Cache-Form. Der erste anonyme Web-Besucher cached "kein Native-Bridge-Button, mit Web-Button"; der erste eingeloggte Native-Besucher cached "mit Bridge-Button, kein Web-Button" — das Schicksal des nächsten hängt davon ab, in welchem Cache-Slot er landet.
Gleicher Ansatz wie #117:
Der Web-Button — kein Branch, immer rendern:
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only blendet ihn unter Native-UA per CSS aus. Das vom CDN gecachte HTML ist immer identisch, der Button immer da, UA entscheidet über die Anzeige — CSS ist eine clientseitige Entscheidung, der Cache nimmt nicht teil.
Der Native-Bridge-Button — verschoben in ein Lazy-Personalize-Frame:
<% if mobile_hotwire? %>
<turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# /personalize/topic_new_button Template %>
<%= turbo_frame_tag "topic-new-button" do %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
Im Hauptseiten-HTML steckt nur noch der <turbo-frame>-Container und der Web-Button — beides verzweigt nicht nach Nutzer. Sämtliches "je nach Nutzer rendern oder nicht"-Markup wurde in den Personalize-Endpunkt verschoben, der private, no-store ist und nie ins CDN gelangt.
Jedes Symptom sah im Moment wie ein anderer Bug aus. "Content missing" wirkte wie ein Hotwire-Native-Problem, der Flash-Leak wie ein Cookies/Session-Konfigurationsproblem, der fehlende Button wie ein Logikfehler im View-Template.
Stellt man die drei PRs nebeneinander, gibt es nur eine Ursache: Der Inhalt des HTML-Bodys hängt von Request-Eigenschaften ab, die außerhalb des Cache-Keys liegen. Die Aufgabe des CDN ist, HTML nach Cache-Key zu cachen — es kann unmöglich wissen, dass ein bestimmtes HTML nur für eingeloggte Nutzer, nur für Hotwire Native oder nur für Requests mit Flash korrekt ist.
Drei wiederverwendbare Muster zur Wurzelbeseitigung:
Variabilität aus dem gecachten Body herausziehen — alles nutzerabhängige Markup wird zu einem Lazy-Turbo-Frame, gerendert von einem private, no-store-Personalize-Endpunkt. Das HTML der Hauptseite ist invariant, das CDN cached wie es will und liegt richtig.
Universelle Version rendern, clientseitig ausblenden — z. B. der Web-Button steht immer im HTML, CSS blendet ihn unter Native-UA aus. Der Branch wandert in die CSS/JS-Schicht, das HTML innerhalb des Cache-Keys bleibt einheitlich.
Cache-Grenz-Wächter — bei Responses, in die schon Zustand reingelaufen ist (z. B. Flash), den Cache-Control an der Cache-Grenze auf private, no-store herabstufen. Normaler Traffic bleibt unangetastet.
Der gemeinsame Nenner der drei Muster: gecachtes HTML und nutzerabhängiges Markup dürfen sich niemals überlappen.
Nach dem Deploy bleibt eine Aufgabe: Vergiftete CDN-Einträge laufen nicht von selbst ab, TTLs reichen bis zu einer Woche. Schreibe einen einmaligen Rake-Task cloudflare:purge_personalized_pages, der alle verdächtigen Pfade aktiv purged — sonst taucht der Bug bis zum natürlichen Ablauf des Caches immer wieder auf.
PR #122 wurde im selben Zeitraum entdeckt. Gleiche Form, andere Bug-Sorte — wert, separat erwähnt zu werden, weil er denselben unsichtbaren Mechanismus mit den Cache-Poisoning-Bugs teilt.
Die /following-Seite lädt die nächste Seite via Lazy-Turbo-Frame (Standard-Infinite-Scroll). Der src des Frames wird von einem Helper namens set_load_more_path berechnet — er bestimmt die Folgeseiten-URL anhand des aktuellen Controllers/Action.
def set_load_more_path(page:, anchor_id: nil)
if controller_name == "coins" && action_name == "show"
path = coin_symbol_path(...)
elsif controller_name == "posts" && action_name == "hot"
path = hot_path(...)
# ... viele Branches ...
elsif controller_name == "users" && action_name == "index"
path = users_path(page: page, ...)
# ...
else
path = posts_path(page: page, ...) # ← Fallback
end
end
Die Action posts#following steht nicht in der Branch-Liste, fällt also auf den letzten posts_path-Fallback — also /posts, den Explore-Feed.
Effekt: Seite 1 von /following ist korrekt (die Controller-Action setzt selbst @posts = following_posts), ab Seite 2 holt sich das Lazy-Frame still und heimlich /posts?page=2 und lädt Explore-Feed-Inhalt — Posts von Leuten, denen du nicht folgst, treiben in deinen Feed.
Der Fix ist kurz:
elsif controller_name == "posts" && action_name == "following"
path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)
Das ist nicht dieselbe Kategorie wie die drei Cache-Poisoning-Bugs — der falsche Branch in set_load_more_path hat nichts mit dem CDN zu tun. Aber er teilt denselben unsichtbaren Mechanismus: Inhalte, die ein Lazy-Turbo-Frame lädt, reviewst du nicht aktiv.
Bei #117 302te das Frame im Fehlerfall lautlos, bei #119/#121 war der Inhalt im Cache falsch ohne dass man es sah, bei #122 war der Frame-Pfad falsch und du hättest es nicht bemerkt, wenn du nur die erste Seite ansiehst. Sobald „dynamischer Inhalt" in einem Lazy-Frame steckt, musst du den Zustand nach dem Laden des Frames aktiv reviewen — was Claude im PR-Review für mich gefangen hat, ist genau diese „Was passiert, nachdem das Frame geladen ist?"-Checkliste.
Wenn deiner Rails-App ein CDN vorgeschaltet ist (Cloudflare, Fastly, egal welches), liegst du mit hoher Wahrscheinlichkeit in mindestens einer der folgenden Konstellationen:
public_expires_in / expires_in ..., public: true einen if user_signed_in?-Branch?flash direkt im Layout gerendert? Wird ein public_expires_in-Pfad gecacht, wenn Flash vorhanden ist?mobile_hotwire? geschützt ist und auf cachebaren Pfaden auftaucht?src-Endpunkt: nutzt sein Controller before_action :authenticate_user!? Wird ein nicht eingeloggter Request ein 302 produzieren?src-Pfad eines Lazy-Turbo-Frames von einem Helper berechnet? Hat dieser Helper einen Fallback-Branch? Ist der Fallback eine falsche URL?Lass Claude diese fünf Punkte durchgehen — wenn einer trifft, ist das dein nächstes PR-Material. Solche Bugs tauchen nicht von selbst auf, weil Lazy-Frames lautlos versagen, das CDN lautlos trifft und CSS lautlos verbirgt. Drei lautlose Mechanismen aufeinandergestapelt — und ein Bug kann sich monatelang in Production verstecken.