Rebounder Tech Blog

Written by the people who actually run these systems in production.

Server Component Crash Behind a 200 OK Response

Published About 4 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

A 200 status and a successful JSON parse don't prove the response matches the contract — as T never checks the shape at runtime, so an out-of-contract 200 can crash a Server Component.

Conclusion

A 200 status and a successful JSON parse don’t guarantee the response shape matches the contract. (await res.json()) as T only tells the compiler what to believe — it checks nothing at runtime. Pass a 200 response missing a required field straight downstream, and a React Server Component built on that assumption crashes.

Symptom

kimiteras-portal has a function, fetchV2Metrics (src/lib/v2-metrics.ts), that pulls monthly effectiveness metrics — impressions, taps, dwell seconds — from a v2 API. The top of that file spells out an explicit invariant in a comment:

Fallback contract: on any failure whatsoever (unconfigured, unlinked, 401/404/422/5xx,
network error), never throw — return { ok:false, reason, status? }. The caller (the
report screen) falls back safely to manual ad_metrics or an "unlinked" state.

The implementation matched that contract for network errors, JSON parse errors, 401/404, and other HTTP errors — each returned { ok: false, reason } via try/catch and branching, as promised.

Except the one path where res.ok was true and the JSON parsed cleanly returned { ok: true, data } with zero validation of what was actually inside:

if (res.ok) {
  try {
    const data = (await res.json()) as V2Metrics;
    return { ok: true, data };
  } catch (e) {
    return {
      ok: false,
      reason: `parse_error: ${e instanceof Error ? e.message : String(e)}`,
    };
  }
}

The caller, V2MetricsPanel (a Server Component), pulls totals out of that data and reads impressions and dwell_seconds off it directly to render the screen:

export default function V2MetricsPanel({ data }: { data: V2Metrics }) {
  const t = data.totals;
  const isLive = data.source === "live";

  const secondary = [
    { label: "Impressions", value: num(t.impressions) },
    { label: "QR / link taps", value: num(t.taps) },
    { label: "Dwell (total)", value: formatDwell(t.dwell_seconds) },
    { label: "Questions", value: num(t.asks) },
  ];

If the v2 API returned a 200 without a totals field, data.totals would be undefined, and t.impressions would throw — crashing the whole component. This was caught in code review before it ever reached a real user’s screen.

Cause

The V2Metrics type declares totals as required, not optional:

export type V2Metrics = {
  advertiser_id: string;
  company_name: string;
  period: string; // "YYYY-MM"
  tz: string;
  totals: V2MetricsTotals;
  by_school?: V2MetricsSchoolRow[];
  contracts?: V2MetricsContract[];
  generated_at: string;
  source: "monthly_reports" | "live" | string; // finalized | live
};

But fetchV2Metrics handed the JSON straight through with (await res.json()) as V2Metrics. as is a statement to the compiler, not a runtime check. When the v2 API actually returned a 200 without totals, TypeScript had no way to catch it at compile time — data passed on to the caller still carrying the type “this is a V2Metrics,” whether it actually was one or not.

In other words: the function’s own header comment promised “never throw on any failure,” and the one path exempt from that promise was the one path that looked most trustworthy — the 200. Network errors and parse errors were both guarded by try/catch, while “200, but the JSON shape doesn’t match the contract” — contract drift — sat completely unguarded.

The fix

A shape check on totals was added to the path where res.ok is true and the JSON parses cleanly:

if (res.ok) {
  let data: V2Metrics;
  try {
    data = (await res.json()) as V2Metrics;
  } catch (e) {
    return {
      ok: false,
      reason: `parse_error: ${e instanceof Error ? e.message : String(e)}`,
    };
  }
  // A 200 with an out-of-contract shape (missing totals) still falls back
  // instead of crashing V2MetricsPanel, which reads totals directly. This
  // covers the "never throw" contract across every 200 path, not just some.
  if (!data || typeof data.totals !== "object" || data.totals === null) {
    return { ok: false, reason: "parse_error: missing totals" };
  }
  return { ok: true, data };
}

If data.totals isn’t an object, or is null, the response is no longer treated as a success — it returns { ok: false, reason: "parse_error: missing totals" } and falls back instead. That stops “a 200 means the shape matches” from being something only a type assertion promises, and makes it something checked at runtime too. The caller falls back to the manual ad_metrics display, the same as any other failure mode.

Preventing a repeat

The fix didn’t stop at the guard — it added a test simulating a 200 response missing totals, to lock the regression in place:

it("200 but out-of-contract shape (missing totals) -> parse_error (falls back instead of crashing)", async () => {
  vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
    jsonResponse({ advertiser_id: ADV, period: YM }) // no totals
  );
  const r = await fetchV2Metrics(ADV, YM);
  expect(r.ok).toBe(false);
  if (r.ok) throw new Error();
  expect(r.reason).toMatch(/missing totals/);
});

Anywhere else a codebase accepts an external API’s response with nothing but as T, the same gap can exist. Any path that treats HTTP status and res.ok as proof of safety, without validating the actual shape of the payload, is worth a second look.

Frequently asked questions

Q1Why didn't TypeScript's types catch this?

The assertion `as V2Metrics` only tells the compiler to trust the shape — it performs no runtime check. When the external API actually returned a 200 without a totals field, TypeScript couldn't detect it, and the code compiled cleanly regardless.

Q2Shouldn't a 200 status mean the response is safe?

Not necessarily. Here, a 200 with a successful JSON parse could still omit a required field, depending on the external API's own implementation or a later change on its side. The status code only tells you the request succeeded — not that the payload matches your contract.

Q3How do you guard against this kind of contract drift?

Don't treat res.ok as proof of success by itself — validate that required fields actually exist and have the right shape before calling it a success. The fix here adds a typeof data.totals !== 'object' guard on the 200 path, with a test covering the missing-field case.

Environment verified

  • Next.js 16.2.7 / React 19.2.4 / TypeScript ^5 — kimiteras-portal
  • Introduced 2026-06-10, caught and fixed the same day in code review

What this article is based on

  • TypeScript file lines 1-16commit 19c214e
  • TypeScript file lines 42-52commit 19c214e
  • TypeScript file lines 127-137commit 19c214e
  • TypeScript file lines 20-29commit 19c214e
  • TypeScript file lines 127-142commit 9236e0c
  • TypeScript file lines 208-216commit 9236e0c

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.