ShipLocal build series · Part 7
Why Next.js Looks Fine Locally but Breaks Through a Tunnel
Three problem classes we found testing a real Framer Motion portfolio — not a todo-list demo.
We learned more about ShipLocal in one week of real-app testing than we did in months of happy-path demos.
We weren’t testing a toy app. We were testing a real Next.js portfolio with Framer Motion: hero animations, motion.header, useInView, code-split chunks, dev HMR — all the stuff that makes modern “it works on localhost” feel like production.
And the tunnel “worked”… but the page did weird things:
- The navbar and some text appeared immediately.
- The hero and section headers (Framer Motion) appeared much later.
- Another app didn’t “load slowly” — it reloaded forever.
If you build developer tools, this is a good sign. It means your product is finally being tested in the real world — where the bugs are never “one bug.”
This post is the overview: the three problem classes we found and the debugging approach that helped us separate “slow HTML” from “slow JavaScript” from “dev runtime breakage.”
Then we’ll go deeper in Parts 8–10.
The clue we almost missed: HTML was fast
The first app looked like this:
- HTML appears quickly (navbar, basic text).
- Then you wait.
- Then the Framer Motion parts animate in, and some section headers finally show.
That symptom split matters.
If ShipLocal were “just slow,” you’d expect everything to arrive slowly.
Instead you see:
HTML arrives
↓
Some content visible immediately
↓
Later...
↓
Motion components animate in
That’s usually hydration.
Next.js is roughly:
SSR HTML
↓
Browser downloads JS
↓
React hydrates
↓
Framer Motion runs
↓
Animations start / useInView fires
If the JS bundle is delayed, React can’t hydrate, and Framer Motion can’t run. The page looks “partly loaded,” even though the HTML arrived fine.
So our first instinct shifted from “proxy is slow” to “the tunnel is delaying JavaScript delivery.”
The other app was a different class of failure: continuous reload
The second app didn’t feel “slow.” It felt broken:
- it loads,
- then reloads,
- then reloads again,
- forever.
When you see a reload loop through a tunnel, don’t start by profiling throughput. Start by asking:
- Are we breaking the dev runtime (HMR / Fast Refresh)?
- Are we injecting anything into HTML that the dev server interprets as a change?
- Are WebSockets failing and forcing the dev server into fallback behavior?
In our case, that question list was basically the roadmap.
The three classes of tunnel bugs (from this week)
1) Hydration looks “slow” because JavaScript is slow
Symptoms:
- SSR shell is fast: navbar and text appear quickly.
- Interactive/motion components appear later.
- Anything that starts at
opacity: 0until hydration stays invisible longer than normal.
Where to look first:
- DevTools → Network → filter
JS - Watch
_next/static/chunks/* - Are bundles taking 8–15 seconds through the tunnel?
Likely root causes (tunnel-side):
- buffering every asset instead of piping
- high overhead transport (base64-in-JSON)
- too many sequential requests (lots of chunks) amplifying per-request latency
Deep dive: When HTML Is Fast but JavaScript Is Slow (Part 10)
2) Continuous reload loops (dev runtime interference)
Symptoms:
- “It keeps refreshing.”
- DevTools → Network → WS shows reconnects or failed upgrades.
- Or the page reloads even though you never touched code.
Two common causes:
- HMR WebSockets aren’t proxied, so Next/Vite can’t maintain the dev socket.
- HTML injection (overlay) triggers Fast Refresh / full reload, causing reinjection, causing another reload.
Deep dives:
- The Feedback Overlay Reload Loop (Part 8)
- Proxying Webpack HMR Over a Tunnel (Part 9)
3) “Partial UI” bugs that are side effects (IntersectionObserver timing)
Symptoms:
- Body text shows, but section titles (often
motion.header) lag or don’t appear. useInViewnever becomestrue“on time.”
This one is usually a side effect, not the primary bug:
- if hydration is late, observers attach late
- if an injected overlay shifts layout, the observer fires at a different time
- if the page keeps reloading, the observer never gets stable time to fire
The simplest test is still a good test:
// temporarily:
const isInView = true;
If everything appears instantly, your observer logic is fine — something else is delaying/altering the conditions.
The debugging loop we now use
When a user says “the tunnel is slow” we now follow a short checklist:
- Network → JS: are
_next/static/chunks/*slow? - Network → WS: is
/_next/webpack-hmrconnected or flapping? - View source / HTML: are we injecting scripts into dev HTML?
- CSP: would
Content-Security-Policyreject the injected overlay script? - Run diagnostics:
shiplocal doctorand paste the output.
This is the difference between guessing and diagnosing.
What changed in ShipLocal because of this week
We didn’t “optimize the tunnel” generically. We made targeted fixes that map to the three classes:
- Dev overlay safety: skip injection on dev bundler HTML markers and dedupe injection in production pages
- HMR support: relay browser WebSocket upgrades through the tunnel to the local dev server
- CSP safety: skip overlay injection when CSP would block the script
- Benchmarks: add a
shiplocal doctorcommand so users can report “slow” with data
These changes improve correctness first — not just speed.
What’s next in this series
This article is the overview. The next three posts are the deeper dives:
- Part 8: The Feedback Overlay Reload Loop We Didn't See Coming
- Part 9: Proxying Webpack HMR Over a Tunnel
- Part 10: When HTML Is Fast but JavaScript Is Slow
And once those land, we’ll publish a more skimmable reference:
- Part 30: Three Classes of Tunnel Bugs