Building in public often means talking about shiny new features, but in mature production applications, the most critical engineering work is usually brownfield problem solving—iterating on top of years and years of code logic and/or production system decisions.
On DEV (powered by the open-source Forem codebase), we have a hybrid architecture that blends Rails server rendering, Fastly edge caching, and lightweight client-side navigation (via InstantClick). This setup delivers sub-100ms page transitions, but partial page swaps combined with aggressive edge caching create delicate deployment challenges.
We shipped a fix PR #23789 which helps solve for an inherently delicate caching mismatch issue in web navigation. This is something that has existed practically since the beginning of DEV, and we have had several flakey fixes for the problem at times, but I feel good about this being a step in the right direction — albeit not a perfectly complete fix by any means.
The Problem: Cache Mismatches Across Deployments
When we deploy new CSS updates, Rails generates new asset digest hashes (e.g., views-v2.css replaces views-v1.css).
-
Full Page Load: A user loads an updated homepage. The browser receives full HTML and loads the latest
v2stylesheets in<head>. -
Internal Navigation (Partial Page Swap): The user clicks an article link. Rather than performing a full browser reload, InstantClick requests the article in the background and swaps only the inner
#page-contentcontainer into the existing DOM. -
The Edge Cache Trap: DEV aggressively caches article HTML fragments at Fastly with surrogate keys. Many articles were cached before the latest deploy, meaning the cached HTML fragment still referenced
v1stylesheets.
Why the Previous Solution Was Fragile
To prevent pages from rendering with outdated CSS, we had previously added client-side logic to inspect the incoming page's expected stylesheet paths and swap <link rel="stylesheet"> elements in the DOM dynamically.
In practice, this was brittle:
- When navigating from the fresh homepage (
v2) to an older edge-cached article (v1), the client script comparedcurrentHref (v2) !== expectedHref (v1). - It assumed the incoming page represented the "target" state and initiated an involuntary downgrade of the DOM back to
v1stylesheets. - If
v1asset files had been pruned after deployment, the browser failed with 404s; if they loaded, new UI components suddenly broke because their updated CSS was ripped out mid-session. - Attempting to asynchronously swap multiple stylesheet tags across both
<head>and<body>introduced CSS cascade race conditions and flashes of unstyled content (FOUC).
The Solution: Repurposing Internal Navigation Parameters
Instead of treating this as a client-side DOM mutation problem, we realized it was fundamentally a cache partitioning problem.
For years, Forem has quietly passed an internal query parameter—?i=i—on background AJAX requests so Rails knows to render a lightweight partial layout instead of the full HTML document shell.
In PR #23789, we repurposed this parameter:
-
Combined Style Fingerprinting: On initial full-page load, Rails computes a deterministic 10-character hash from the combined digests of the core stylesheets (
minimal,views, andcrayons) and places it on<body>asdata-style-fingerprint:
<body ... data-style-fingerprint="cc9ed033eb">
- Parameterizing Internal Navigation: When InstantClick preloads or fetches a link, it reads the active session's fingerprint and appends it to the internal URL:
https://dev.to/user/post?i=cc9ed033eb
(If data-style-fingerprint is missing—such as on tabs open during deploy cutover—it gracefully falls back to ?i=i).
-
Natural Cache Partitioning at Fastly: Fastly's edge configuration already whitelists
iin its safe parameter list. Because the query parameter is part of the cache key:- When a user on a
v2session requests/post?i=v2_fingerprint, Fastly checks for a cachedv2fragment. - If missing, Fastly fetches a fresh fragment from Rails compiled with
v2styles. - Stale
v1cached fragments are simply bypassed and eventually expire naturally.
- When a user on a
Deleting the DOM Swapping Logic: With cache partitioning in place, incoming partial fragments are guaranteed to match the active DOM's stylesheet version. We completely deleted the client-side stylesheet replacement script. The browser's active
<link>tags remain static throughout the user session.
Brownfield Development Takeaways
- Solve at the Cache Layer, Not the DOM Layer: Trying to coordinate DOM mutations, asynchronous CSS loading, and cascade precedence in client-side JavaScript is almost always more fragile than letting the edge cache serve the right HTML version from the start.
-
Repurpose Existing Primitives: We didn't need a new router, an edge computing rewrite, or custom HTTP headers. Repurposing our existing
?i=...internal parameter gave us exact cache keying with zero extra infrastructure overhead. - Pragmatic Improvements Over Perfect Rewrites: This fix doesn't magically prevent every theoretical edge-case during rolling deploys, but it definitively solves the most disruptive issue. The system is far less delicate, and we now have a clean, predictable pattern to build upon.
Happy coding!
Top comments (0)