Add an LLM to a Web Application You Have to Maintain

Your LLM call works in a test script. Production needs a server-side proxy, timeouts, retries that tell a rate limit from a spend cap, and output checks.

The demo took an afternoon. A script sends a prompt, a response comes back, it looks great. Then you have to put it in the actual application, the one with users, a deployment process and an on-call rotation, and every question changes.

The short answer: to add an LLM to a web application safely, call the provider only from your server, never from the browser; keep the API key in the server environment; give every request a timeout; retry only the errors the provider says are temporary, honoring its Retry-After header; treat the model’s output as untrusted input and validate it; and log each request’s ID. Everything else is detail on top of those six decisions.

This is the pillar for a cluster on maintaining LLM features in PHP and JavaScript applications. It is based on provider and platform documentation read on 2026-09-11. Provider APIs change faster than almost anything else a web developer depends on, so check the linked pages against the date before relying on any specific behavior.

Decision 1: the browser never talks to the provider

The architecture that survives production has three hops: browser to your server, your server to the provider, and back. Your server is a thin proxy with opinions: it authenticates your user, applies your limits, builds the prompt, calls the provider and shapes the response.

The alternative, calling the provider directly from front-end JavaScript, puts your API key in every visitor’s browser. There is no way to hide a secret in code you ship to the client. A key there is a key published.

Browser-to-provider calls also run into cross-origin rules, and developers sometimes “fix” the resulting error by looking for a way to relax them. That is solving the wrong problem. Which side of a request owns a CORS error, and why the answer is usually “your own server”, is set out in CORS errors on localhost, fixed from the right side.

Decision 2: the key lives in the environment, not the repository

On the server, read the key from an environment variable. The PHP manual for getenv() (read 2026-09-11) gives the signature and says the function returns false when the variable does not exist:

getenv(?string $name = null, bool $local_only = false): string|array|false

Check for false at startup and fail loudly, rather than sending an empty key and debugging a 401 later. The same page warns that under a SAPI such as FastCGI, getenv() returns the SAPI-set value even if putenv() set a local one, which matters if you set variables in more than one place.

The rest of key handling, including keeping keys out of commits and out of the reach of your own AI tools, is covered in AI coding assistants and your API keys: a security checklist.

Decision 3: every request has a timeout, at every hop

An LLM request can take far longer than a normal API call, and a request with no timeout can hold a PHP worker or a Node process until the platform kills it. Set a timeout on the server-to-provider call, and set one in the browser too.

In the browser, AbortSignal.timeout() (MDN, read 2026-09-11) returns a signal that aborts after the given number of milliseconds, with a TimeoutError DOMException. Shortened from MDN’s example:

try {
  const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
  // …
} catch (err) {
  if (err.name === "TimeoutError") {
    // Timeout from the abort signal
  } else if (err.name === "AbortError") {
    // Abort from user action
  }
}

MDN distinguishes the two names: TimeoutError means your timeout fired, AbortError means something else, such as the user, cancelled the request. Show them different messages.

On the provider side, Anthropic’s API errors page (read 2026-09-11) recommends its streaming API or its batch API “for long-running requests, especially those over 10 minutes,” and warns that some networks drop idle connections, which can make a long non-streaming request fail without a response.

Decision 4: a 429 is not one error

This is the single most expensive misunderstanding in LLM integrations, and generic “use exponential backoff” advice makes it worse.

Some 429 responses mean “slow down and try again.” Others mean “you are out of budget,” and retrying them will never succeed. Your retry logic has to tell them apart.

OpenAI’s error codes guide (read 2026-09-11) lists several 429 variants. “Rate limit reached for requests” is caused by “sending requests too quickly,” and the fix is to “Pace your requests and follow the Retry-After header when it’s present.” But the same status also covers “Organization spend limit reached”, “Project spend limit reached” and “Organization usage limit reached”, whose fixes are changing a limit in settings or contacting support. OpenAI’s rate limits guide (read 2026-09-11) is direct: “Don’t retry quota, billing, or other errors that require you to take action.”

Anthropic’s errors page draws the same line from the other side. Its 429 rate_limit_error can mean a rate limit, a usage tier’s monthly spend cap, or a workspace spend limit, and it notes that “A tier spend-cap 429 has no retry-after header and keeps failing until access resumes.” The same page says a spend limit you set yourself generally returns a 400 rather than a 429, and it notes an exception: in Claude Code workspaces that limit can come back as a 429.

What that means for your code:

ResponseWhat it meansWhat your code should do
429 with Retry-AfterTemporary rate limitWait at least that long, then retry a bounded number of times
429 identified as a spend or usage limitBudget exhaustedDo not retry. Alert a human, degrade the feature
500, or 503 / 529 overloadedProvider-side troubleRetry with exponential backoff, bounded
400, 401, 403, 404Your request, key or permissionsDo not retry. Log and fix
TimeoutUnknownRetry once if the operation is safe to repeat

Read the error type and message the provider returns, not just the status code. Both providers document typed error bodies for exactly this reason.

If you use an official SDK, know what it already does. Anthropic’s page states its SDKs retry transient failures with exponential backoff “twice by default, honoring the retry-after header when present,” and that each SDK accepts a maximum-retries option. Wrapping your own retry loop around an SDK that already retries multiplies the attempts.

Decision 5: the model’s output is untrusted input

Everything the model returns should be handled like user input from a stranger: validated, escaped for its output context, and never executed or inserted into a query unchecked. A model asked for JSON will usually return JSON, and occasionally return prose, a truncated object, or JSON wrapped in commentary.

Parse defensively and check the shape you expect before using any field. When the parse fails in the browser, the error is often not about the model at all: a proxy timeout or an error page arrives as HTML, and JSON.parse fails on the first character, as explained in Unexpected token in JSON at position 0. Log the raw body when parsing fails so you can tell which case you have.

Decision 6: log the request ID, and plan for the stream

Log the provider’s request ID for every call. Anthropic’s errors page says every response includes a unique request-id header and that the same ID appears in error bodies; it is what support asks for. Log it next to your own request ID, the latency, the status and the error type. When something fails in production, that line is how you find out what happened. The stack trace from your own side then tells you where; reading a stack trace you did not write helps with that part.

Streaming changes error handling. Streaming responses to the browser makes long answers feel fast, and it is how most chat-style features work. Two documented caveats:

  • Anthropic’s errors page notes that with a server-sent events stream, “an error can occur after the API returns a 200 response.” A 200 status does not mean success; your stream handler has to watch for error events inside the stream.
  • MDN’s guide to using server-sent events (read 2026-09-11) says the server must respond with text/event-stream, and warns that when not used over HTTP/2, browsers limit SSE to a very low number of open connections (6) per browser and domain. A user with several tabs open can starve their own requests.

What maintaining it actually involves

The work that continues after launch, and what each spoke in this cluster covers:

  • Limits and retries. Reading Retry-After, telling the two kinds of 429 apart, and adding jitter so retrying workers do not all wake up at once.
  • Keys. Where the key lives, why it must never reach front-end code, and the first ten minutes after one leaks.
  • Timeouts and streaming. The request that never came back, and streaming to the browser without buffering the whole answer.
  • Output. Handling the model that promised JSON and sent prose.
  • Cost and repetition. Estimating cost before you ship, and caching when the same question keeps arriving.
  • Deprecation. Providers retire models. Keep the model identifier in configuration, not scattered through code, so switching is a config change and a test run, not a search through the codebase.

Honest limits

None of this makes the model’s answers correct. A well-built integration fails gracefully, recovers from provider trouble and never leaks a key, and it can still return a confident wrong answer to your user. Decide before launch what the feature does when the model is wrong, not only when the API is down. For some features, the right design includes a human review step. For others, it means not shipping the feature.

FAQ

Can I call an LLM API directly from front-end JavaScript? Not safely. The request would need your API key, and anything shipped to the browser is readable by every visitor. Route calls through your own server, which holds the key in its environment.

Should I retry every 429 from an LLM provider? No. OpenAI’s documentation says not to retry quota, billing or other errors that require action, and Anthropic’s says a tier spend-cap 429 has no retry-after header and keeps failing until access resumes. Retry only rate-limit responses, and honor Retry-After.

Why did my streamed response fail after a 200 status? With server-sent events, the provider can report an error inside the stream after the 200 has been sent, as Anthropic’s API errors page notes. Handle error events in the stream, not only the HTTP status.

What should I log for each LLM request? At least the provider’s request ID, your own request ID, latency, status code and error type. The request ID is what the provider’s support team asks for.

Written by

Shah Alom

Shah Alom is the founder and writer behind Ebuhu, where he covers PHP, WordPress development, plugin and theme development, debugging, practical programming techniques, and AI-assisted coding. Drawing on hands-on web development experience, he focuses on clear, practical guidance that helps developers understand how things work, avoid common mistakes, and write more reliable code.

Leave a Reply

Your email address will not be published. Required fields are marked *