Why Your Vite Proxy Fails Inside WebContainer — And How to Actually Fix It
A deep technical dive into why outbound API calls from WebContainer-based apps fail with socket hang up and CORS errors, what the real architecture looks like under the hood, and the proxy pattern that actually works.
If you're building an app that runs inside WebContainer and needs to call an external API, you've probably hit a wall. The socket hang up error. The CORS error. The mysterious net::ERR_FAILED (from service worker). You've tried every Vite proxy configuration, added changeOrigin: true, set secure: false — nothing works.
I spent days debugging this while building a browser-based app builder that generates React apps connected to an SSE streaming backend. Here's what's actually happening under the hood and the pattern that finally solved it.
The Setup
Imagine you're building a platform where users describe an app in natural language, an LLM generates the code, and the app runs live in a WebContainer preview. The generated app needs to call an external API for AI responses — a POST request to an SSE streaming endpoint.
Simple, right?
const response = await fetch('https://api.example.com/workflow/stream', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + API_KEY },
body: JSON.stringify({ query: userMessage }),
});
This works in any normal browser. Inside WebContainer? It explodes.
What's Really Happening Under the Hood
Here's the critical fact that took me too long to discover:
WebContainer's Node.js runtime is compiled to WebAssembly and runs inside the browser. ALL network requests — even from "server-side" Node.js code — go through the browser's Service Worker. CORS applies to everything.
This means:
fetch()from your React component → browser fetch → CORS applieshttp.request()from Node.js inside WebContainer → Service Worker → browser fetch → CORS still applies- Vite's
server.proxy→node-http-proxy→ Service Worker → browser fetch → CORS STILL applies
The Vite proxy provides zero benefit inside WebContainer. It doesn't bypass CORS because it's not a real server-side proxy — it's another browser fetch in disguise.
The Three Things That Break
1. CORS (The Obvious One)
Your generated app runs on https://{random-id}.local-credentialless.webcontainer-api.io. The target API is on a completely different origin. Without Access-Control-Allow-Origin from the API server, the browser blocks it.
Even if your API returns CORS headers on GET requests, it might not handle the OPTIONS preflight correctly for POST with custom headers like Authorization.
2. HTTP/2 vs node-http-proxy (The Hidden One)
This is the one nobody talks about. Vite uses node-http-proxy which does not support HTTP/2. When your target API is behind Cloudflare (or any modern CDN), the TLS handshake negotiates HTTP/2 via ALPN. The proxy tries to speak HTTP/1.1 on an HTTP/2 connection:
Error: socket hang up
at connResetException (internal/errors.js:...)
This is why ngrok works but your staging URL doesn't. ngrok negotiates HTTP/1.1 with Node.js clients. Cloudflare aggressively prefers HTTP/2. Different ALPN negotiation = different outcomes from the same proxy code.
3. Cross-Origin Isolation (The Prerequisite)
WebContainer needs SharedArrayBuffer, which requires:
Cross-Origin-Embedder-Policy: credentialless
Cross-Origin-Opener-Policy: same-origin
Plus HTTPS (mandatory — self.crossOriginIsolated is always false on plain HTTP, except localhost). If you deploy your app builder on http://your-server-ip:5173, WebContainer won't even boot.
What the Successful Platforms Do
I researched how every major AI app builder handles this:
| Platform | How They Handle API Calls |
|---|---|
| Lovable | Supabase Edge Functions as server-side proxy. Browser never calls external APIs. |
| v0 (Vercel) | Next.js Route Handlers / Server Actions. Same-origin calls, server proxies externally. |
| bolt.new (paid) | Built-in CORS proxy that intercepts requests server-side. Not available in open-source forks. |
The pattern is universal: Browser → Same-Origin Proxy → External API. The browser never makes cross-origin API calls to third-party services.
The Solution: Host Server as CORS Proxy
Instead of fighting WebContainer's limitations, work with them. Your app builder already has a server (Remix, Next.js, Express — whatever). Use it as a proxy.
Architecture
Generated App (WebContainer browser)
→ fetch('https://your-studio.com/api/proxy') ← CORS headers set by you
→ Your server (real Node.js) ← Server-to-server, no CORS
→ External API ← SSE stream back
→ Response streams through both layers
Step 1: CORS-Enabled Proxy Route
// Your server-side API route (Remix example)
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
};
// Handle preflight
export const loader = async ({ request }) => {
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
return new Response('Method not allowed', { status: 405 });
};
export const action = async ({ request }) => {
const { apiKey, endpoint, payload } = await request.json();
// Real server-side fetch — no CORS, no Service Worker, no HTTP/2 issues
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
// Stream SSE back with CORS headers
return new Response(response.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
...CORS_HEADERS,
},
});
};
Step 2: Vite Middleware for Dev Server
The Remix action handles the proxy logic, but the Vite dev server's default OPTIONS handling may strip your custom headers. Add middleware:
// vite.config.ts (your app builder server)
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url?.startsWith('/api/proxy')) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
}
next();
});
}
Step 3: Generated App Calls Your Server
The LLM generates code that calls your Studio's proxy URL — not the external API directly:
const PROXY_URL = 'https://your-studio.com/api/proxy';
async function callAPI(message, onChunk) {
const response = await fetch(PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apiKey: 'user-key',
endpoint: 'https://api.example.com/stream',
payload: { query: message },
}),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = JSON.parse(line.slice(6));
if (data.event === 'message_delta') onChunk(data.delta);
}
}
}
Why This Works
| Layer | What Happens | CORS? |
|---|---|---|
| Generated app (WebContainer) | Browser fetch() to your server | Preflight passes (you set *) |
| Your server (real Node.js) | Server-side fetch() to external API | No CORS (server-to-server) |
| External API | Returns SSE stream | N/A |
You control the proxy server, so you set Access-Control-Allow-Origin: *. The external API doesn't need CORS headers because your server calls it server-to-server.
Other Things You'll Need
HTTPS Is Mandatory
self.crossOriginIsolated is always false on plain HTTP. Use a Cloudflare Tunnel (free) or Let's Encrypt for HTTPS.
COEP Must Be credentialless
Use Cross-Origin-Embedder-Policy: credentialless instead of require-corp. The require-corp mode blocks the WebContainer iframe from loading because StackBlitz's domain doesn't send CORP headers.
Allow All Hosts in Vite
When accessing through a tunnel or reverse proxy, Vite blocks requests with unknown Host headers:
server: {
host: '0.0.0.0',
allowedHosts: true,
}
Kill Zombie Processes
WebContainer spawns Node.js processes that don't clean up. If your port is always taken on restart, add a pre-start script that kills processes on your port before Vite starts.
The Debugging Cheat Sheet
| Symptom | Cause | Fix |
|---|---|---|
socket hang up | HTTP/2 negotiation with node-http-proxy | Don't use Vite proxy inside WebContainer. Use server proxy. |
net::ERR_FAILED (from service worker) | WebContainer Service Worker can't reach target | Target needs CORS or use server proxy |
CORS error on preflight | Target API missing Access-Control-Allow-Origin | Use server proxy with CORS headers |
SharedArrayBuffer requires crossOriginIsolated | Missing HTTPS or COEP/COOP headers | Add HTTPS + credentialless COEP + same-origin COOP |
403 Forbidden on tunnel URL | Vite's host check blocking unknown hostname | Set allowedHosts: true in vite.config.ts |
| Preview works locally but not deployed | Plain HTTP on deployed server | WebContainer needs HTTPS for SharedArrayBuffer |
TL;DR
- WebContainer's Node.js networking = browser
fetch(). CORS applies to everything, including Vite proxy. - Vite proxy inside WebContainer is useless. It doesn't bypass CORS.
- node-http-proxy doesn't support HTTP/2. Cloudflare targets fail with
socket hang up. - The fix: Route API calls through your own server with CORS headers. This is what Lovable, v0, and bolt.new all do.
- HTTPS is mandatory.
crossOriginIsolatedrequires it.
If you're building on any WebContainer-based platform, skip the debugging and use a server-side proxy pattern from the start.
Have questions or ran into a different variation of this problem? Reach out — I've probably hit that wall too.