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), decide loading="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_back in an error handler back to what actually triggers the failing request. If the trigger is a background fetch embedded in the same page redirect_back sends 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?

← All writing