Skip to content

39 β€” Calling Your Own API from the Browser

Level: Advanced Β· Time: ~35 min Β· Prerequisites: Lesson 38 β€” The DOM and Events, Typed


Why this matters

A fetch from a browser is not the same operation as a fetch from Node, and the differences are the ones that cost beginners whole afternoons: the browser applies the same-origin policy to the response, refuses to explain why in the error message, and hands you a rejected promise with no status code. A network request is also the only common operation that can still be in flight when the user has moved on, so a correct interface needs an explicit answer to two questions: what is on screen while this is happening, and what if it never finishes? This lesson answers both, with the front end from Lessons 37 and 38 talking to a real API.

Same-origin, CORS and the error that explains nothing

An origin is scheme, host and port, so http://localhost:5173 and http://localhost:8787 are different origins even though both are on your machine. When your page fetches another origin, the browser sends the request, receives the response, and then refuses to let your code read it unless that response carries Access-Control-Allow-Origin for your origin. The request happened; the data is discarded.

The console shows a CORS message, but your promise rejection is only TypeError: Failed to fetch. There is no status code, because the browser deliberately withholds the details β€” this is a security boundary, not a convenience β€” and two practical consequences follow:

  • A blocked cross-origin request looks exactly like a server that is not running. Check the Network tab for a response and the console for a CORS line before rewriting working code.
  • Anything outside the CORS-safelisted set β€” a Content-Type: application/json on a POST, or a custom header such as Authorization β€” makes the browser send a preflight OPTIONS request first. The server must answer that with Access-Control-Allow-Methods and Access-Control-Allow-Headers, or the real request is never sent. Accept: application/json is safelisted, so a simple GET does not preflight.

The request has four states, so model four states

Booleans cannot describe a request: isLoading plus hasError plus data permits combinations that never happen and forgets the one that matters β€” loaded, and empty. Use a discriminated union, then let the compiler force you to answer for every case:

type ViewState =
  | { readonly status: "idle" }
  | { readonly status: "loading" }
  | { readonly status: "success"; readonly days: readonly ProductionDay[] }
  | { readonly status: "error"; readonly message: string };

function label(state: ViewState): string {
  switch (state.status) {
    case "idle": return "Choose a month, then load.";
    case "loading": return "Loading production data";
    case "error": return state.message;
    case "success":
      return state.days.length === 0
        ? "No production recorded for that period."
        : `${state.days.length} days loaded`;
  }
}

function render(state: ViewState): void {
  button.disabled = state.status === "loading";
  statusEl.textContent = label(state);
  if (state.status === "success") renderTable(state.days);
}

Empty is a success with no rows β€” not an error, and not a spinner that never stops. The switch is doing real work: add a fifth variant to ViewState and label stops compiling, because TypeScript sees a path that returns nothing.

A typed client, validated at the boundary

Keep every fetch call in one module, take the base URL as a parameter, and treat the response body as unknown until you have proved its shape. The parameter is what makes the client testable outside a browser:

import type { ProductionDay } from "./production";

export class ApiRequestError extends Error {
  constructor(readonly status: number, message: string) {
    super(message);
    this.name = "ApiRequestError";
  }
}

function isProductionDay(value: unknown): value is ProductionDay {
  if (typeof value !== "object" || value === null) return false;
  const { date, region, kwh } = value as Record<string, unknown>;
  return typeof date === "string" && typeof region === "string" && typeof kwh === "number";
}

function isProductionDayList(value: unknown): value is ProductionDay[] {
  return Array.isArray(value) && value.every(isProductionDay);
}

export function createSolarClient(baseUrl: string) {
  return {
    async listDays(signal?: AbortSignal): Promise<ProductionDay[]> {
      const response = await fetch(`${baseUrl}/api/days`, {
        headers: { Accept: "application/json" },
        signal,
      });
      if (!response.ok) {
        throw new ApiRequestError(response.status, `Request failed (${response.status})`);
      }
      const payload: unknown = await response.json();
      if (!isProductionDayList(payload)) {
        throw new ApiRequestError(response.status, "The server sent an unexpected shape");
      }
      return payload;
    },
  };
}

Aborting stale requests

Type a month, press load, change your mind and press load for another month: two requests are now in flight, and the first may finish second, overwriting the new data with old. The fix is one AbortController per request, cancelled when the next one starts:

let inFlight: AbortController | null = null;

async function load(month: string): Promise<void> {
  inFlight?.abort();
  const controller = new AbortController();
  inFlight = controller;

  render({ status: "loading" });
  try {
    const days = await client.listDays(controller.signal);
    if (controller.signal.aborted) return;
    render({ status: "success", days });
  } catch (error) {
    if (error instanceof DOMException && error.name === "AbortError") return;
    render({ status: "error", message: describe(error) });
  }
}

Errors a person can act on, and disabling what is in flight

TypeError: Failed to fetch and Request failed (503) are messages for you, not for the person using the page. Map each failure to a sentence that names the next action, keep the raw error for the console, and put the mapping in a describe(error: unknown): string function that the catch branch above calls:

Failure What the user should read
TypeError, no response "The browser could not reach the server. Check your connection."
404 "No production data exists for that period."
500 and above "The server is unavailable. Try again shortly."
any other non-2xx the status code and the server's own message

For writes rather than reads, two rules keep the interface honest. Disable the control while the request is in flight β€” the union above already does exactly that through button.disabled, true only in loading β€” so a double click cannot send the same write twice. And when the outcome is nearly certain, render the change immediately and keep the previous state to roll back to if the request fails: optimism without a rollback path is how interfaces claim to have saved data they never sent.

Base URLs, the dev proxy and testing against a mock server

The base URL differs per environment, so it belongs in .env files, as Lesson 37 set up: point VITE_API_BASE at http://localhost:8787 in .env.development and at your real host in .env.production, then hand it to the client once with const client = createSolarClient(import.meta.env.VITE_API_BASE ?? "").

In development that only works if the backend sends CORS headers. The usual alternative removes the problem instead of solving it: proxy "/api" to http://localhost:8787 with changeOrigin: true under server.proxy in vite.config.ts, so the browser talks to its own origin and never makes a cross-origin request at all.

With the proxy in place, set VITE_API_BASE= (empty) in .env.development and the client's /api/days is same-origin. Do not carry that idea into production: a proxy is a development convenience, and Vite does not run inside your deployed bundle.

Because the client takes its base URL as an argument, its data layer can be tested against a throwaway HTTP server β€” Vitest plus node:http, no browser and no automation dependency:

// src/api.test.ts
import { createServer } from "node:http";
import { afterAll, beforeAll, expect, test } from "vitest";
import { createSolarClient } from "./api";

const server = createServer((_request, response) => {
  response.writeHead(200, { "content-type": "application/json" });
  response.end(JSON.stringify([{ date: "2026-03-14", region: "Dakar", kwh: 18.4 }]));
});

beforeAll(async () => {
  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
});

afterAll(() => server.close());

test("returns typed days from a real HTTP response", async () => {
  const address = server.address();
  if (typeof address !== "object" || address === null) throw new Error("No port assigned");
  const client = createSolarClient(`http://127.0.0.1:${address.port}`);
  await expect(client.listDays()).resolves.toHaveLength(1);
});

server.listen(0, ...) asks the operating system for a free port, so the test never collides with your dev server, and Node 20's global fetch means no HTTP client package is needed. Install the tools with npm install --save-dev vitest @types/node and run npx vitest run; pointing the same tests at a real backend is a one-argument change.


Common mistakes

Mistake What happens The fix
Reading a CORS failure as a bug in your code The promise rejects with a bare TypeError and no status, so you rewrite working code Check the console's CORS line and the Network tab first; fix the server headers or add a dev proxy
Assuming a GET never preflights A JSON POST or a custom header triggers an OPTIONS your server does not answer, and the request dies Allow the methods and headers the browser asks for, or keep the request safelisted
Trusting await response.json() It returns any, so a renamed server field flows into typed code and breaks far away Annotate const payload: unknown and validate with a type guard
Letting requests race A slow earlier response overwrites a newer one and the table shows the wrong month One AbortController per request, abort the previous, ignore AbortError
Catching the abort and showing an error The user sees a failure message after a request they deliberately replaced Return early from the AbortError branch and leave the UI loading

Try it

  1. Add src/api.ts with ApiRequestError, the two guards and createSolarClient, install the test tools with npm install --save-dev vitest @types/node, and set up the /api proxy in vite.config.ts plus VITE_API_BASE= in .env.development.

  2. Replace the table's loading logic with the four-state union, label and render. Expected: pressing load shows Loading production data with the button disabled, then either a row count or No production recorded for that period. β€” never two states at once.

  3. Add the AbortController code and prove the race is gone: throttle to Slow 3G in the Network tab, click load for March, then immediately for April. Expected: only April's data renders, the March request shows as cancelled, and no error appears on screen.

  4. Stop the server and press load. Expected: the reachable-server sentence from describe, not TypeError. Start it again, confirm the button is enabled, then run npx vitest run, npx tsc -b and npm run build and fix everything they report.


Key takeaways

  • The same-origin policy is enforced by the browser on the response, so a CORS failure reaches your catch as a bare TypeError; the console and the Network tab are the real error report.
  • Idle, loading, success and error as one discriminated union is the smallest request model that cannot describe nonsense, and empty results are a success with no rows.
  • response.ok plus an unknown body and a type guard are the two lines that keep untrusted JSON out of typed code.
  • One AbortController per request, aborted when a newer one starts, stops stale responses overwriting fresh data β€” and a cancelled request is not an error to show.

Check yourself

  1. Your page fetches http://localhost:8787/api/days from http://localhost:5173 and fails with TypeError: Failed to fetch. Name the two most likely causes and how to tell them apart in ten seconds.
  2. Why does adding an Authorization header turn a simple GET into a request that needs a preflight?
  3. A slow request for March resolves after a fast request for April. Which two guards keep the table correct, and what happens if you keep only the first?

Next

Lesson 40 β€” A Backend in TypeScript