DEV Community

babycat
babycat

Posted on

localhost Was Fine, Production Wasn't: A CORS Debugging Retrospective for a Deployed AI Chat Widget

Last week I built a small streaming chat widget against MonkeyCode's free model tier, tested it locally until the responses felt instant, and deployed the static build to the free server option. The first browser test on the deployed URL failed before a single token arrived, and the console showed a CORS error I had never seen during development. The same request from curl streamed perfectly, which made the browser failure feel personal.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

This is a debugging retrospective, not a product review, because the bug was entirely mine. I assumed localhost and production were the same environment, and they were not. The fix took about twenty minutes once I stopped blaming the model and started reading the preflight request.

The symptom: a frozen chat with a 200 in the network tab

The widget looked fine: input field, send button, streaming output area. I clicked send on the deployed URL, and nothing appeared. DevTools showed the POST to the model API returning 200, yet the browser refused to hand the response to my JavaScript. The console said the request had been blocked by CORS policy, and my code sat in the streaming state forever because I only handled HTTP errors, not fetch rejections.

Why did curl work? Because curl does not enforce CORS; the browser does. Think of CORS as the browser's bouncer: the server may have answered the request, but the browser checks the guest list before handing the response to your JavaScript. That is the first lesson of this retrospective: a successful server response is not the same thing as a response the browser will let you read. My widget had two states, streaming and done, and the missing third state, error, is exactly where the bug lived.

The preflight I forgot

My fetch sent an Authorization header with the model API key, which makes the request non-simple and triggers a preflight. The browser first sent an OPTIONS request asking permission:

OPTIONS /v1/chat
Origin: https://my-widget.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
Enter fullscreen mode Exit fullscreen mode

The server answered the OPTIONS with a status the browser could accept, but the response carried no Access-Control-Allow-Origin header. That single missing header is enough for the browser to abort the whole exchange, even though the actual POST would have succeeded. The network tab showed both requests, but I had only been looking at the POST.

The painful part was that localhost worked perfectly. Why? Because I had configured a Vite dev proxy, which made every request same-origin and skipped CORS entirely. The deployed static site had no proxy, so the browser talked directly to the model API and hit the wall. The environment matrix explains the whole story:

Environment Request path Result
localhost + Vite proxy same-origin /api/chat works
deployed static site cross-origin model API blocked by CORS
deployed site + server proxy same-origin /api/chat works
curl direct model API works, no CORS enforced

The fix: a same-origin proxy that streams

I considered three options and picked the one that did not depend on the model provider changing anything:

Option Works? Notes
Ask the provider to add CORS headers Not guaranteed Free tiers rarely expose CORS configuration
Remove the Authorization header No The endpoint requires a key
Proxy through the same origin that serves the static files Yes Full control, key stays server-side

The proxy is a small Node server that serves the static build and forwards /api/chat to the model API. The browser only ever talks to its own origin, so CORS disappears:

// server.js — static files + streaming proxy on the same origin
import http from "node:http";
import { readFile } from "node:fs/promises";

const MODEL_URL = "https://model-api.example.com/v1/chat";

http
  .createServer(async (req, res) => {
    if (req.url.startsWith("/api/chat") && req.method === "POST") {
      let body = "";
      for await (const chunk of req) body += chunk;

      const upstream = await fetch(MODEL_URL, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.API_KEY}`,
        },
        body,
      });

      res.writeHead(upstream.status, {
        "Content-Type": upstream.headers.get("content-type") ?? "application/json",
      });

      for await (const chunk of upstream.body) res.write(chunk);
      res.end();
      return;
    }

    const file = req.url === "/" ? "index.html" : req.url.slice(1);
    try {
      const data = await readFile(`dist/${file}`);
      res.writeHead(200, { "Content-Type": "text/html" });
      res.end(data);
    } catch {
      res.writeHead(404);
      res.end("Not found");
    }
  })
  .listen(process.env.PORT || 3000);
Enter fullscreen mode Exit fullscreen mode

The client change is even smaller: point the fetch at the relative URL and drop the key from the browser entirely.

const res = await fetch("/api/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ messages }),
});
Enter fullscreen mode Exit fullscreen mode

A side benefit I did not expect: the API key stopped leaking into the browser bundle, which the old code was doing without me noticing. That alone was worth the refactor, because a leaked key in a public bundle is a credential incident waiting for a scraper.

Reusable debugging techniques

The specific failure was CORS, but the process transfers to any environment difference between localhost and production:

  1. Reproduce with the same client. curl proves the server works, not that the browser can read the response. Test with the exact headers your app sends.
  2. Read the preflight, not just the POST. The OPTIONS request and its response headers tell you exactly what the browser asked for and what the server refused.
  3. Enumerate environment differences. Proxy, origin, TLS, base URL, headers — write them down before you start guessing.
  4. Add a CORS smoke test to your QA matrix. A tiny page that fetches with the same headers catches this in minutes.
  5. Make the UI surface the failure. A silent CORS block leaves users staring at a frozen chat, so treat fetch rejections as first-class states.

The accessibility angle you cannot skip

A blocked request is invisible to the user until the UI says something, so my widget needed a real error state with a clear announcement and a way forward. The state machine should have at least four states, and each one needs a visible UI and an announcement:

State UI Announcement
idle input enabled, button ready
streaming partial text, stop button "Response started"
error alert box, retry button "Request failed"
cancelled input enabled "Stopped"
{error && (
  <div role="alert" className="chat-error">
    <p>The request failed: {error.message}</p>
    <button type="button" onClick={retry}>Retry</button>
  </div>
)}
Enter fullscreen mode Exit fullscreen mode

When the fetch rejects, move focus to the retry button, announce the failure through the alert region, and keep the input usable so the user can edit the prompt. If your UI cannot represent error, it will represent it as a frozen streaming, which is exactly what happened to me.

Limitations and who should not use this approach

This fix assumes you control the server that serves the static files, so if your host only serves static assets and cannot run a proxy, you need a separate proxy host or a provider that sends the right CORS headers. Check whether the model API already allows your origin before building anything, because if it does, the proxy is unnecessary complexity. And remember that a proxy is not a security boundary by itself: it keeps the key out of the browser, but you still need proper auth on the server side.

If you want to reproduce this failure cheaply, MonkeyCode is open source, and its free model tier (currently a 10M-token allowance) plus free server option give you a place to deploy the same widget and watch the preflight yourself. The bug is the point: it is a five-minute fix once you actually see the OPTIONS request, and now you know where to look.

Top comments (0)