Handle JSON API Responses: Parsing and Validation
TL;DR
- Never call
response.json()without checkingresponse.okand thecontent-typeheader first- Validate every API response against a schema (Zod, Ajv, or io-ts) before using the data in your application
- Implement retry logic with exponential backoff for transient failures (5xx, network errors)
- Use streaming for responses larger than 10MB to avoid blocking the main thread
Table of Contents
- The Three Layers of Safe Parsing
- Layer 1: HTTP Status and Headers
- Layer 2: JSON Parsing with Error Handling
- Layer 3: Schema Validation
- Building a Robust API Client
- Handling Partial and Paginated Responses
- Streaming Large JSON Responses
- Retry Logic and Error Recovery
- Common API Response Anti-Patterns
- Frequently Asked Questions
The Three Layers of Safe Parsing
Most developers parse API responses like this:
const data = await fetch('/api/users').then(r => r.json());
This one-liner hides five potential failure points:
- Network failure (fetch throws)
- Non-2xx status code (server error, auth failure)
- Non-JSON response body (HTML error page, empty body)
- Malformed JSON (truncated response, encoding issues)
- Unexpected data shape (field renamed, type changed, null where object expected)
Production-grade parsing handles all five. The approach uses three layers: HTTP validation, JSON parsing, and schema validation.
Layer 1: HTTP Status and Headers
Before touching the response body, validate the HTTP layer:
async function fetchJson(url: string): Promise<unknown> {
const response = await fetch(url);
// Layer 1a: Check HTTP status
if (!response.ok) {
const errorBody = await response.text().catch(() => 'No error body');
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
errorBody
);
}
// Layer 1b: Check content type
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new ApiError(
`Expected JSON but received ${contentType}`,
response.status,
await response.text().catch(() => '')
);
}
return response; // Pass to Layer 2
}
Why check content-type? When a server crashes, it often returns an HTML error page instead of JSON. Calling .json() on an HTML response produces a cryptic parse error. Checking the header first gives you a meaningful error message.
Layer 2: JSON Parsing with Error Handling
Parse the JSON with explicit error handling:
async function parseJsonResponse(response: Response): Promise<unknown> {
const text = await response.text();
if (!text || text.trim() === '') {
throw new ApiError('Empty response body', response.status, '');
}
try {
return JSON.parse(text);
} catch (parseError) {
throw new ApiError(
`Invalid JSON: ${(parseError as Error).message}`,
response.status,
text.substring(0, 500) // Include first 500 chars for debugging
);
}
}
Why parse text first instead of using response.json()? Two reasons. First, you can check for empty bodies. Second, if parsing fails, you have the raw text for debugging. response.json() discards the raw text on failure, making it harder to diagnose the issue.
Use our JSON Formatter to paste raw API response text and see exactly where the syntax error is. It highlights the line and character position of the first error.
Layer 3: Schema Validation
Even valid JSON can have the wrong shape. A field renamed from user_name to username in a backend update will not cause a parse error, but your frontend will display undefined values.
Zod validation (recommended for TypeScript):
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'user', 'viewer']),
createdAt: z.string().datetime(),
preferences: z.object({
theme: z.enum(['light', 'dark']).default('light'),
notifications: z.boolean().default(true),
}).optional(),
});
type User = z.infer<UserSchema>;
function validateUser(data: unknown): User {
const result = UserSchema.safeParse(data);
if (!result.success) {
console.error('Validation failed:', result.error.flatten());
throw new ValidationError('Invalid user data', result.error);
}
return result.data;
}
Ajv validation (for vanilla JavaScript):
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const validateUser = ajv.compile({
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['id', 'name', 'email'],
additionalProperties: false,
});
For more on JSON Schema vocabulary and advanced validation patterns, see our JSON Schema guide.
Building a Robust API Client
Combine all three layers into a reusable API client:
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get<T>(path: string, schema: z.ZodType<T>): Promise<T> {
const url = `${this.baseUrl}${path}`;
// Layer 1: HTTP
const response = await fetch(url);
if (!response.ok) {
throw new ApiError(`HTTP ${response.status}`, response.status);
}
// Layer 2: JSON
const text = await response.text();
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new ApiError('Invalid JSON response', response.status);
}
// Layer 3: Schema
const result = schema.safeParse(data);
if (!result.success) {
throw new ValidationError('Schema validation failed', result.error);
}
return result.data;
}
}
// Usage
const api = new ApiClient('https://api.example.com');
const user = await api.get('/users/123', UserSchema);
// user is fully typed and validated
Test your API endpoints with our API Request Tester and format the responses with our JSON Formatter before building your schema definitions.
Handling Partial and Paginated Responses
APIs that return lists typically use pagination. Handle it correctly:
const PaginatedSchema = z.object({
data: z.array(UserSchema),
pagination: z.object({
total: z.number(),
page: z.number(),
perPage: z.number(),
hasMore: z.boolean(),
}),
});
async function fetchAllUsers(): Promise<User[]> {
const allUsers: User[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const result = await api.get(
`/users?page=${page}&per_page=50`,
PaginatedSchema
);
allUsers.push(...result.data);
hasMore = result.pagination.hasMore;
page++;
// Safety limit
if (page > 100) break;
}
return allUsers;
}
Always include a safety limit on pagination loops. A bug in the API's hasMore field can cause an infinite loop that hammers the server.
Streaming Large JSON Responses
For responses larger than 10MB, parsing the entire body at once can block the main thread and cause the UI to freeze.
Streaming with the Fetch API:
async function streamJsonArray(url) {
const response = await fetch(url);
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 });
// Process complete JSON objects from the buffer
const objects = extractCompleteObjects(buffer);
for (const obj of objects.complete) {
processItem(JSON.parse(obj));
}
buffer = objects.remaining;
}
}
For simpler streaming, use libraries like oboe.js that handle the buffering and parsing automatically. For more on parsing performance and streaming, see our JSON parsing performance guide.
Retry Logic and Error Recovery
Transient failures (network blips, 503 responses, timeouts) should be retried automatically:
async function fetchWithRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const isLastAttempt = attempt === maxRetries;
const isRetryable = error instanceof ApiError &&
(error.status >= 500 || error.status === 429);
if (isLastAttempt || !isRetryable) throw error;
const delay = baseDelay * Math.pow(2, attempt);
const jitter = delay * 0.1 * Math.random();
await new Promise(r => setTimeout(r, delay + jitter));
}
}
throw new Error('Unreachable');
}
Retry rules:
- Retry on 5xx errors and 429 (rate limited)
- Never retry on 4xx errors (client errors are not transient)
- Use exponential backoff with jitter to avoid thundering herd
- Set a maximum retry count (3 is standard)
Common API Response Anti-Patterns
Anti-pattern 1: Trusting the happy path
// Dangerous: no error handling
const { data } = await fetch('/api/users').then(r => r.json());
users.value = data.users; // crashes if data or users is undefined
Anti-pattern 2: Swallowing errors
try {
const data = await fetchUsers();
setUsers(data);
} catch (e) {
// Silent failure - user sees nothing
}
Anti-pattern 3: Using any to bypass type checking
const data: any = await response.json();
// TypeScript thinks this is fine, but data could be anything
processUser(data.user.profile.name); // runtime crash
For the complete JSON developer reference including more patterns and anti-patterns, see our pillar guide: The Complete JSON Guide for Web Developers.
The Debuggers builds production-grade API integrations for web and mobile applications with comprehensive error handling and monitoring.
Frequently Asked Questions
Should I use response.json() or parse text manually?
For quick prototyping, response.json() is fine. For production code, parse text manually. Reading the response as text first lets you handle empty bodies, preserve the raw text for debugging when parsing fails, and add custom error messages. The performance difference is negligible.
How do I handle APIs that return different JSON shapes for errors?
Define separate schemas for success and error responses. Check the HTTP status code first to determine which schema to apply. Alternatively, use a discriminated union in Zod where the success field determines which branch of the schema is used. This is cleaner than trying one schema and falling back to another.
What is the best validation library for JSON API responses?
Zod is the best choice for TypeScript projects because it infers types from schemas, eliminating duplication between runtime validation and static types. Ajv is faster for raw validation speed and is better for vanilla JavaScript projects. For Python, use Pydantic. For Go, use the encoding/json package with custom unmarshalers.
How do I debug a JSON API response that looks correct but causes bugs?
First, paste the raw response into our JSON Formatter to visualise the structure. Then compare the actual response shape against your schema definition. Common issues include: field names that changed case (camelCase vs snake_case), null values where objects are expected, numbers returned as strings, and arrays that are empty instead of absent. Schema validation catches all of these automatically.
Test your API endpoints now
Use our free API Request Tester to send requests and inspect JSON responses. Format and validate the results with our JSON Formatter.
Building API integrations? The Debuggers provides backend development and API architecture consulting.
Found this helpful?
Join thousands of developers using our tools to write better code, faster.