# Optimistic Transition: Why Next.js `loading.tsx` Isn’t Showing (App Router + middleware/proxy delay)

## PUBLISHED DECEMBER 24, 2025

[Satoshi Toyama](/content/author/satoshi-toyama/index.html), Founding Engineer

You open [Giselle Playground](https://studio.giselles.ai/playground), pick an app, type a request, and **Run**.

The best version of this experience is immediate:

- you get an instant “we’re moving” acknowledgment
- you see destination-shaped UI (task context + status)
- you never wonder if your click registered

This instant feeling feels natural to users. However, in Next.js, it's surprisingly hard to achieve using only the framework's standard features.

In Next.js, when you implement a flow like an input screen → a results screen, it’s common to split it into separate pages. And it’s also common to use `loading.tsx` (or `loading.js`) to improve the experience until the next page renders. However, `loading.tsx` can only show after the next page’s response has started coming back—meaning while your proxy (formerly middleware) is still running, `loading.tsx` won’t show at all. That moment of dead air is what I’ll call the **No UI gap**.

In this post, I’ll use a **sample application** to show two things:

- demonstrate how the No UI gap appears while `proxy.ts` is running
- the UX pattern we use in Giselle to keep the experience snappy anyway: an **Optimistic Transition** (a destination-shaped overlay)

## 1) Why “just add a spinner” isn’t good enough

A spinner is fine for **“this button is busy.”**

But Playground is a destination-shaped flow:

- after “Run”, users expect to land on a **task surface**
- they want to see what’s happening _in context_ (agent, input, status)
- they want reassurance that the app has transitioned state

A spinner on the _source_ screen says: “wait here.”  
A destination-like shell says: “you’re already on your way there.”

That difference matters most when navigation can’t even start rendering.

## 2) Next.js is great at smooth transitions—until it isn’t

In the App Router, two big tools improve perceived performance **after the destination starts rendering**:

### `loading.tsx`: route-level render-time loading UI

A `loading.tsx` file can show while a segment is being prepared.

But the critical condition is timing:

- `loading.tsx` can only render once Next.js has actually entered the destination segment and started rendering it.

### Streaming (Suspense): progressive rendering once the response starts

Streaming helps once the server starts producing bytes for the destination.

Again, same boundary:

- streaming only begins after rendering begins.

### One boundary to remember

**Both tools help after render starts.**

If the delay happens _before_ Next.js can start rendering the destination, these tools can’t show yet.

That’s exactly what middleware / proxy delay can create.

## 3) The surprise: `loading.tsx` and streaming don’t start under middleware / `proxy.ts` delay

To make this concrete, we’ll use a small sample app you can try in the browser: [Next.js Loading Lab](https://nextjs-loading-lab.vercel.app/).

If you add a deterministic delay in request-time code (middleware/proxy), the timeline looks like this:

1. user triggers navigation (`Link`, `router.push`, action → redirect, etc.)
2. the request is held upstream by middleware / proxy delay
3. only when that finishes can Next.js route + render the destination
4. only _then_ can `loading.tsx` / streaming appear

So when you feel the No UI gap, it’s not that Next “forgot” your `loading.tsx`.  
It’s that **render-time UI can’t start yet** because the request hasn’t reached rendering.

This is an ordering problem (request-time vs render-time), not a bug.

## 4) Try it yourself: feel the No UI gap with proxy delay

If this still feels abstract, let’s try it. The goal is to make the boundary undeniable by introducing a delay you can feel consistently.

### Steps

1. Open [Next.js Loading Lab](https://nextjs-loading-lab.vercel.app/).
2. In the left menu, select **`loading.tsx`**.
3. On the right, enter a keyword and click **Open Summary**.  
   - You should see the skeleton UI immediately, then the result page renders and fetches the Wikipedia summary.

Here is the baseline behavior **without proxy delay**: the `loading.tsx` skeleton shows immediately after click.

4. Now go to **Proxy (middleware)** in the left menu and check **Enable proxy**.
5. Set **Proxy delay** to something noticeable (e.g. 500–1500ms) and click **Save**.
6. Repeat steps 2–3.  
   - This time, you’ll notice a “dead air” period: the skeleton won’t appear until after the proxy delay has elapsed.

With proxy delay enabled, notice the **No UI gap**: nothing can render (including `loading.tsx`) until the proxy finishes.

### What to observe

Under proxy delay, look for:

- **Before start**: dead air (no route loading UI yet)
- **After start**: the usual App Router loading/streaming behaviors finally become available

That split is the entire point of this post.

## 5) The fix: “Optimistic Transition” (destination-shaped overlay that bridges the No UI gap)

You can see this solution working in the same sample app. In [Next.js Loading Lab](https://nextjs-loading-lab.vercel.app/), enable **Proxy (middleware)** with a noticeable delay, then open **Optimistic Transition** and click **Open Summary**. Unlike `loading.tsx`, the overlay shows immediately on click—bridging the dead air while the proxy is still running.

With proxy delay enabled, this is what it looks like: the overlay appears **immediately on click**, even while the proxy is still running.

When the No UI gap happens, the problem isn’t “we need a loader.”

The real problem is:

> **the destination hasn’t started yet—so the user can’t see evidence of progress or state transition.**

The solution is to render **immediate, destination-shaped UI** on the client _before_ the destination can render.

### Definition

An **Optimistic Transition** is UI that appears immediately and acts as **a proxy of the final destination experience**—so the user feels the transition has started even before the destination can render.

- not a generic spinner
- not “please wait”
- a faithful stand-in for the destination’s structure, showing what’s already known and honestly placeholdering what’s unknown

### Why it works (relative to the boundary)

- **`loading.tsx` / streaming** improve what happens **after** render begins.
- **Optimistic Transition** improves what happens **before** render can begin.

This is exactly the gap middleware/proxy delay exposes.

## 6) How Giselle applies it: layout-persistent overlay + store-driven control

To cover the No UI gap, two things must be true:

- the overlay must stay mounted during navigation
- the current screen must be able to show it instantly (synchronously with the user action)

That leads to a clean division of responsibilities:

### Want the code? Start with the demo + the real product

If you’re an engineer, you probably want to verify the claim by reading actual code. Two good starting points:

- **Next.js Loading Lab (repro demo)**: the exact lab used in this post is open-source at [toyamarinyon/nextjs-loading-lab](https://github.com/toyamarinyon/nextjs-loading-lab).

- **Proxy delay (request-time)**: `proxy.ts`
  - **Proxy config cookies**: `app/api/config/route.ts`, `lib/config.ts`
  - **Overlay pattern**: `components/overlay.tsx` (and the shared views under `components/`)
- **Giselle (production OSS)**: Giselle itself is open-source at [giselles-ai/giselle](https://github.com/giselles-ai/giselle).

- Giselle is a monorepo: the **Studio** web app lives under `apps/studio.giselles.ai` (Next.js App Router).
  - Playground routes/UI start here: [`apps/studio.giselles.ai/app/(main)/playground`](https://github.com/giselles-ai/giselle/tree/main/apps/studio.giselles.ai/app/(main)/playground)
  - Tip: search within `apps/studio.giselles.ai` for keywords like `overlay`, `transition`, or `optimistic` to find the real implementation used in the product.

### Placement: mount it in a persistent layout, not in `page.tsx`

If the overlay lives inside a page, it can unmount during navigation—exactly when you need it.

Mount it in a layout that persists across the transition (segment layout or root layout), and render it alongside `{children}`.

### Control: trigger from the source screen (client), render from the layout

The source action (like “Run”) can:

- synchronously show overlay with known context (agent name, input parameters, etc.)
- start the async work / navigation

The overlay can then:

- show truthful status (“Starting…”, “Waiting for response…”, etc.)
- disappear when navigation completes (or when the destination tells it to hide)

### Dismissal: make it explicit

Because a layout-mounted overlay persists, it won’t magically disappear.  
Make hiding explicit on success/failure:

- success path: hide on arrival (destination mounts)
- failure path: hide and show error + recovery affordance

## 7) When to use this pattern (and when not to)

Use Optimistic Transition when:

- the user action is high-intent (Run / Generate / Publish / Checkout)
- “dead air” would break trust
- you can render a truthful destination-like shell immediately
- you can define clear dismiss conditions

Avoid it when:

- navigation is low-intent / link-browsing heavy
- the action is not safely repeatable (unless you have idempotency)
- you can’t define crisp success/failure/cancel handling

Pitfalls to design for:

- **Double-submit**: users click again when they see nothing—disable triggers, consider idempotency keys
- **Failure recovery**: show retry/back/cancel clearly
- **Honesty**: no fake progress; show what you actually know (input, target, status)

## 8) Takeaway

If you remember one thing, make it this:

**Next.js can only show `loading.tsx` / streaming after the destination has started rendering.**

Middleware / proxy delay can block _before_ that point—creating a No UI gap that render-time tools cannot cover.

Decision rule:

- if your slow step is **before** rendering begins → use an **Optimistic Transition** (overlay)
- if your slow step is **after** rendering begins → use **`loading.tsx` and streaming**

In Giselle Playground, we optimize for “instant” by making the transition feel continuous—even when navigation hasn’t started rendering yet.
