Writing
The Turbo Frame that 403'd on every page load, forever
September 30, 2026 · Amit Solanki
This comes from a restaurant ordering platform I built on Spree Commerce, with Square’s point-of-sale as the source of truth for the menu (case study). Its admin is Spree’s own, with a read-only “viewer” role that can see orders and products but change nothing. That role is where this bug lived. It’s one of a pair: the other is about fixing the permission itself, and this one is about why the symptom looked the way it did. Each stands on its own.
The bug looked like a caching problem, and I spent a while treating it as one.
A viewer-role admin logged into a real store, visited Orders, and got exactly the right page with a red “Authorization Failure” banner stapled to it. Refresh: same. Log out and back in: same. Try Products instead: same. Every load, every time, for a page the role was correctly permitted to see and was, in fact, correctly seeing.
That is what a stale cache looks like from the outside. Something wrong got stored once, and keeps getting served no matter what you do.
It was the opposite of stored. The flash was being generated fresh on every single visit, by correct code doing exactly what it was written to do, because something invisible on the page was failing in the background every time the page rendered, and that failure’s error handler pointed at the one destination guaranteed to cause it again.
A self-renewing error and a cached one are indistinguishable from the outside. That is most of why this took as long as it did.
A dialog that fetches before you ever open it
Both pages render spree_admin’s shared export-modal partial - the “Export” button and its
dialog. The dialog’s contents live in a Turbo Frame:
<turbo-frame id="export_modal" src="<%= new_admin_export_path %>">
Turbo Frames with a src attribute default to loading="eager" - confirmed directly against
Turbo’s own docs, not assumed from behavior. Eager means exactly what it sounds like: the frame
issues its GET request the moment it’s inserted into the DOM, unconditionally. Not when the
dialog opens. Not when a user hovers the Export button. The instant the surrounding page finishes
rendering - including a hidden, unopened dialog nobody has interacted with yet.
Turbo does have loading="lazy" for exactly this shape of problem - defer the fetch until the
frame is actually visible in the viewport, which a closed dialog isn’t. It would have been the
cleaner fix here. It also lives inside spree_admin’s own shared partial, and this project’s own
rule is never to patch gem source - so loading="lazy" wasn’t an option on the table, and the fix
had to happen somewhere else in the request’s path instead: the permission the eager fetch was
actually asking for.
The part that makes it permanent, not just wrong
A viewer role without export permission means that eager background GET returns a 403. On its own, an eagerly-fetched frame quietly failing wouldn’t be a great experience, but it also wouldn’t be this experience - a full-page red banner, every time, forever. That part comes from what the 403 handler does next:
def redirect_unauthorized_access
if try_spree_current_user
flash[:error] = Spree.t(:authorization_failure)
redirect_back(fallback_location: spree.admin_forbidden_path)
# ...
redirect_back sends the response to wherever the request came from - and the request came from
the Orders page’s own Turbo Frame, embedded in the Orders page itself. The 403 doesn’t redirect
to some unrelated error page; it redirects back to Orders. Which renders the same export-modal
partial. Which contains the same eager src frame. Which fires the same background request.
Which 403s again, next time the page loads, and redirects back to Orders again.
That’s the actual mechanism behind “the right page, plus a permanent error flash”: not one
request failing once, but a background request that fails identically on every single page load,
whose own failure handler’s redirect_back happens to point at the exact page that guarantees the
next failure. The flash isn’t stuck - it’s being regenerated, correctly, by working code, on every
single visit, for as long as the underlying permission gap exists. Fixing the permission - the specific mechanism is its own
companion post, since it turned out to have a genuinely interesting Rails-STI wrinkle
of its own - is what stops the frame from 403ing in the first place, which is what stops there
being anything for redirect_back to loop on.
The lesson, twice
Know your frame’s default loading strategy, especially for anything inside hidden or
conditionally-visible content. loading="eager" being the default (not "lazy") means any
src-bearing Turbo Frame fires the instant it’s in the DOM, whether or not a human has done
anything to warrant it - a closed dialog, a collapsed accordion panel, a tab that isn’t selected
yet. If that request can fail for reasons unrelated to the visible page around it - a permission
gap, a rate limit, a dependency being briefly down - that failure fires on every page load that
happens to render the hidden container, not just the loads where someone actually opens it.
redirect_back is a dangerous default for a background request’s error handler specifically.
It’s a fine default for a user-initiated action that fails - of course send them back to where
they were. For a background fetch a user never directly triggered, “back” is wherever they
already are, which is exactly the page that just triggered the failing request in the first
place. That turns a single bad response into a self-renewing loop with no obvious point of entry
for a fix, because every symptom of the loop looks like it’s coming from the page itself rather
than from something invisible embedded inside it.
The checklist
- A symptom that survives a refresh and a re-login looks exactly like a cache. Before you go hunting for one, check whether something on the page is regenerating the symptom on every render. Cached and self-renewing look identical from outside and have nothing in common to fix.
- Before adding a
src-bearing Turbo Frame inside anything conditionally visible (a dialog, an accordion, an unselected tab), decideloading="eager"vs"lazy"deliberately - don’t inherit the default without checking whether “fetch immediately, always” is actually what you want. - If you can’t control the frame’s loading strategy (shared partial, vendored gem, third-party component), the fix has to happen somewhere else in that request’s path - usually the permission or resource the eager fetch is actually asking for, since you can’t make the fetch itself lazier from the outside.
- Trace any
redirect_backin an error handler back to what actually triggers the failing request. If the trigger is a background fetch embedded in the same pageredirect_backsends the user to, that combination doesn’t resolve - it repeats. - When a bug reads as “the right page plus a symptom that won’t go away,” look for something inside the page firing its own separate, silently-failing request - not a problem with the page’s own render path, which in cases like this is often completely correct.
The platform behind this post, now called Platestead, is written up in the case study: one Rails codebase, a deployment per restaurant brand, and Square and Clover behind one provider-neutral core.
Working through something similar?