Request streams, response cloning, AbortController with timeouts, fetchLater() for beacon-style requests, priority hints, and the patterns that make raw fetch() a serious alternative to axios. Most developers abandoned fetch() too early.
Most developers tried fetch() when it first shipped, hit a wall with error handling (it doesn’t reject on 4xx/5xx), decided it was too low-level, and installed axios. That was 2016. A lot has changed.
In 2026, fetch() is not the bare-bones API you remember. It has cancellation with AbortController and timeout signals, streaming request and response bodies, response cloning for multiple consumers, a priority hint system for controlling resource loading order, and fetchLater() — a new deferred fetch API that finally solves the end-of-session analytics problem that drove everyone to the Beacon API.
This is the complete guide to what fetch() can do in 2026 — and how to build a thin wrapper that gives you everything axios offered without the 13KB bundle cost.
The Reason People Left: Error Handling
First, the thing that drove developers away — and the fix:
// ✗ The confusion: fetch() doesn't throw on 4xx or 5xx
const response = await fetch('/api/users/999')
// response.ok === false — but no error was thrown
// Developers expected an error, got a response object
// ✓ The correct pattern: check response.ok explicitly
const response = await fetch('/api/users/999')
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const user = await response.json()
fetch() only rejects on network errors — connection refused, DNS failure, CORS block. HTTP status codes are always resolved, never rejected. Once you know this, the fix is straightforward. We’ll encode it in a wrapper later.
AbortController: Cancellation and Timeouts
AbortController is the standard mechanism for cancelling any fetch — or multiple fetches at once.
Basic Cancellation
const controller = new AbortController()
// Cancel after 10 seconds
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const response = await fetch('/api/data', { signal: controller.signal })
clearTimeout(timeout)
return await response.json()
} catch (err) {
if (err.name === 'AbortError') {
console.log('Request was cancelled')
return null
}
throw err // re-throw network errors
}
AbortSignal.timeout() — The Cleaner Syntax
Since 2023, AbortSignal.timeout() creates a signal that automatically aborts after a specified millisecond delay — no setTimeout management required:
// ✓ Built-in timeout signal — no manual setTimeout
try {
const response = await fetch('/api/data', {
signal: AbortSignal.timeout(5000) // aborts after 5 seconds
})
return await response.json()
} catch (err) {
if (err.name === 'TimeoutError') {
console.log('Request timed out')
} else if (err.name === 'AbortError') {
console.log('Request was manually cancelled')
} else {
throw err
}
}
TimeoutError and AbortError are distinct — you can tell the difference between a timeout and a manual cancellation.
AbortSignal.any() — Combine Multiple Abort Conditions
// Abort if EITHER the user navigates away OR the timeout fires
const userController = new AbortController()
const response = await fetch('/api/heavy-report', {
signal: AbortSignal.any([
userController.signal, // manual cancellation
AbortSignal.timeout(30_000), // 30 second timeout
])
})
// Cancel manually when the user leaves
function onNavigate() {
userController.abort()
}
Cancelling Multiple Requests with One Controller
// One controller, multiple requests — all cancelled together
const controller = new AbortController()
const { signal } = controller
const [user, orders, preferences] = await Promise.allSettled([
fetch('/api/user', { signal }),
fetch('/api/orders', { signal }),
fetch('/api/preferences', { signal }),
])
// Cancel all three at once
function cancelAll() {
controller.abort()
}
Response Cloning: Reading a Response Multiple Times
A Response body is a readable stream that can only be consumed once. If you try to call .json() twice, the second call fails. response.clone() creates an independent copy:
const response = await fetch('/api/products')
// ✗ Second read fails — body already consumed
const json = await response.json()
const text = await response.text() // TypeError: body already used
// ✓ Clone before consuming
const clone = response.clone()
const json = await response.json() // consumes original
const buffer = await clone.arrayBuffer() // consumes clone — independent
// Common use case: cache the raw response AND parse it
const response = await fetch('/api/products')
const cacheClone = response.clone()
const data = await response.json()
// Store the cloned response in the Cache API for offline use
const cache = await caches.open('products-cache')
await cache.put('/api/products', cacheClone)
Streaming Responses: Process Data as It Arrives
For large responses — CSV exports, log files, AI-generated text — waiting for the entire response before processing is slow and memory-intensive. The Response.body is a ReadableStream that lets you process data as chunks arrive.
Reading a Stream
const response = await fetch('/api/large-export')
if (!response.body) throw new Error('No response body')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let totalBytes = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
// value is a Uint8Array chunk
const chunk = decoder.decode(value, { stream: true })
totalBytes += value.byteLength
// Process each chunk as it arrives
processChunk(chunk)
updateProgress(totalBytes)
}
Streaming a Large Response to the User
async function streamDownload(url, filename) {
const response = await fetch(url)
// Pipe the response stream directly to a file download
const blob = await response.blob()
const anchor = document.createElement('a')
anchor.href = URL.createObjectURL(blob)
anchor.download = filename
anchor.click()
URL.revokeObjectURL(anchor.href)
}
Streaming AI / Server-Sent Chunks
// Streaming LLM response — text arrives token by token
async function streamCompletion(prompt, onChunk) {
const response = await fetch('/api/ai/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
// Server-Sent Events format: "data: {token}\n\n"
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') return
onChunk(JSON.parse(data).token)
}
}
}
}
// Usage
await streamCompletion('Explain fetch() in 2026', (token) => {
outputEl.textContent += token
})
Streaming Request Bodies
You can also stream the request body — useful for sending large files or real-time data:
// Stream a file upload with progress tracking
async function streamUpload(file, onProgress) {
let uploaded = 0
const total = file.size
const stream = new ReadableStream({
start(controller) {
const reader = file.stream().getReader()
function push() {
reader.read().then(({ done, value }) => {
if (done) {
controller.close()
return
}
uploaded += value.byteLength
onProgress(uploaded / total * 100)
controller.enqueue(value)
push()
})
}
push()
}
})
return fetch('/api/upload', {
method: 'POST',
body: stream,
headers: { 'Content-Type': file.type },
// Note: streaming uploads require duplex: 'half' in some environments
duplex: 'half',
})
}
Priority Hints: Control Resource Loading Order
The priority option tells the browser how to prioritise a fetch relative to other requests of the same type. Three values: 'high', 'low', 'auto' (default).
// High priority — critical data needed for first render
const criticalData = await fetch('/api/hero-content', {
priority: 'high'
})
// Low priority — nice-to-have data that can wait
const recommendations = fetch('/api/recommendations', {
priority: 'low' // browser deprioritises this request
})
// Auto — let the browser decide (default behaviour)
const standardData = fetch('/api/products', {
priority: 'auto'
})
Practical Priority Patterns
// Load critical above-the-fold data at high priority
// Load below-the-fold data at low priority
async function loadDashboard() {
const [heroData, chartData] = await Promise.all([
fetch('/api/hero-metrics', { priority: 'high' }).then(r => r.json()),
fetch('/api/chart-data', { priority: 'high' }).then(r => r.json()),
])
renderHero(heroData)
renderCharts(chartData)
// Fire these after critical data is rendered — low priority
const [recommendations, activity] = await Promise.all([
fetch('/api/recommendations', { priority: 'low' }).then(r => r.json()),
fetch('/api/recent-activity', { priority: 'low' }).then(r => r.json()),
])
renderRecommendations(recommendations)
renderActivity(activity)
}
Browser support note: The
priorityoption infetch()is supported in Chrome and Edge. It is expected to reach Baseline Widely Available status in April 2027. Use it as progressive enhancement — browsers that don’t support it simply ignore the hint.
fetchLater(): Deferred Beacon-Style Requests
fetchLater() is the most significant addition to the Fetch API in years. It solves a problem that has plagued analytics and session-tracking implementations since web development began.
The Problem It Solves
You want to send data to the server when the user leaves a page — session duration, final scroll position, last interaction. The existing options all have problems:
unload/beforeunloadevents — unreliable, blocked in modern browsersBeacon API— fires and forgets, can’t update the payload after schedulingfetch()withkeepalive: true— fires immediately, not on page close
fetchLater() queues a deferred request that the browser sends at one of three times — whichever comes first:
- The document is destroyed (tab closed, navigated away)
- After a specified timeout (
activateAfter) - When the browser decides it’s appropriate
// Queue a request that fires when the user leaves
const result = fetchLater('https://analytics.example.com/session', {
method: 'POST',
body: JSON.stringify({ sessionId, startTime }),
activateAfter: 60_000, // or after 1 minute, whichever comes first
})
// Check if it's already been sent
console.log(result.activated) // boolean
The Key Advantage: Updatable Payloads
Unlike sendBeacon(), you can replace a pending fetchLater() request with updated data before it fires:
let pendingBeacon = null
function scheduleSessionBeacon(data) {
// Cancel the previous pending request by scheduling a new one
// (the browser replaces the pending request for the same URL)
pendingBeacon = fetchLater('https://analytics.example.com/session', {
method: 'POST',
body: JSON.stringify(data),
activateAfter: 30_000,
})
}
// Update the beacon as the user interacts
document.addEventListener('scroll', () => {
scheduleSessionBeacon({
sessionId,
scrollDepth: getScrollDepth(),
timeOnPage: Date.now() - sessionStart,
lastAction: 'scroll',
})
})
document.addEventListener('click', () => {
scheduleSessionBeacon({
sessionId,
scrollDepth: getScrollDepth(),
timeOnPage: Date.now() - sessionStart,
lastAction: 'click',
})
})
// The browser sends the LATEST version of the beacon when the user leaves
// No more stale analytics data
Quota and Limits
fetchLater() has size limits to prevent abuse:
- Each reporting origin: 64KB total across all pending requests
- All origins combined: 512KB for the top-level document
// Handle quota exceeded errors
try {
fetchLater('/analytics', {
method: 'POST',
body: largePayload, // may exceed quota
})
} catch (err) {
if (err.name === 'QuotaExceededError') {
// Fall back to sendBeacon with compressed payload
navigator.sendBeacon('/analytics', compressedPayload)
}
}
Browser support:
fetchLater()is currently available in Chrome. The spec is being standardised and cross-browser support is in progress. Always check'fetchLater' in windowbefore using it.
The request Object: Reusable Request Templates
fetch() accepts a Request object, not just a URL string. Request objects are reusable templates with built-in cloning:
// Create a request template
const baseRequest = new Request('/api/users', {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
'X-Request-ID': crypto.randomUUID(),
},
mode: 'cors',
credentials: 'include',
cache: 'no-store',
})
// Clone and customise for specific calls
const getUsers = baseRequest.clone()
const postUser = new Request(baseRequest, {
method: 'POST',
body: JSON.stringify(newUser),
})
const users = await fetch(getUsers).then(r => r.json())
const created = await fetch(postUser).then(r => r.json())
Building a Production fetch() Wrapper
All the above patterns can be composed into a thin wrapper that gives you the ergonomics of axios without the bundle cost:
// lib/api.ts — 50 lines that replace axios for most use cases
const BASE_URL = import.meta.env.VITE_API_URL ?? ''
const DEFAULT_TIMEOUT = 30_000
interface RequestOptions extends RequestInit {
timeout?: number
params?: Record<string, string | number | boolean>
}
class ApiError extends Error {
constructor(
public status: number,
public data: unknown,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
async function request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const { timeout = DEFAULT_TIMEOUT, params, ...init } = options
// Build URL with query params
const url = new URL(BASE_URL + path, window.location.origin)
if (params) {
Object.entries(params).forEach(([k, v]) =>
url.searchParams.set(k, String(v))
)
}
// Combine manual abort + timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
// Merge signals if one was provided
const signal = init.signal
? AbortSignal.any([init.signal, controller.signal])
: controller.signal
try {
const response = await fetch(url, {
...init,
signal,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${getToken()}`,
...init.headers,
},
})
clearTimeout(timeoutId)
// Handle empty responses (204 No Content)
if (response.status === 204) return null as T
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new ApiError(
response.status,
data,
data?.message ?? `HTTP ${response.status}: ${response.statusText}`
)
}
return data as T
} catch (err) {
clearTimeout(timeoutId)
if ((err as Error).name === 'AbortError') {
throw new ApiError(408, null, 'Request timed out or was cancelled')
}
throw err
}
}
// Typed convenience methods
export const api = {
get: <T>(path: string, options?: RequestOptions) =>
request<T>(path, { method: 'GET', ...options }),
post: <T>(path: string, body: unknown, options?: RequestOptions) =>
request<T>(path, {
method: 'POST',
body: JSON.stringify(body),
...options,
}),
put: <T>(path: string, body: unknown, options?: RequestOptions) =>
request<T>(path, {
method: 'PUT',
body: JSON.stringify(body),
...options,
}),
patch: <T>(path: string, body: unknown, options?: RequestOptions) =>
request<T>(path, {
method: 'PATCH',
body: JSON.stringify(body),
...options,
}),
delete: <T>(path: string, options?: RequestOptions) =>
request<T>(path, { method: 'DELETE', ...options }),
}
export { ApiError }
Usage
import { api, ApiError } from '@/lib/api'
// Basic GET
const users = await api.get<User[]>('/users')
// With query params
const filtered = await api.get<User[]>('/users', {
params: { status: 'active', role: 'admin' }
})
// POST with typed response
const created = await api.post<User>('/users', {
name: 'Taylor Otwell',
email: 'taylor@laravel.com',
})
// With custom timeout
const report = await api.get<Report>('/reports/annual', {
timeout: 120_000, // 2 minutes for slow report generation
})
// With custom abort signal (for component cleanup)
const controller = new AbortController()
const data = await api.get<Data>('/data', {
signal: controller.signal,
})
// Error handling
try {
await api.post('/subscriptions', { planId })
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 422) showValidationErrors(err.data)
if (err.status === 402) redirectToUpgrade()
if (err.status === 408) showTimeoutMessage()
}
}
fetch() vs axios: The Honest Comparison
| Feature | fetch() | axios |
|---|---|---|
| Bundle cost | 0KB (native) | ~13KB gzipped |
| Error on 4xx/5xx | Manual check (response.ok) | Automatic throw |
| Request cancellation | AbortController | CancelToken (deprecated) / AbortController |
| Timeout | AbortSignal.timeout() | timeout option |
| Request interceptors | Manual (wrapper) | Built-in |
| Response interceptors | Manual (wrapper) | Built-in |
| Upload progress | ReadableStream (complex) | onUploadProgress (simple) |
| Download progress | ReadableStream (native) | Limited |
| Response streaming | Native | Limited |
| Automatic JSON | Manual .json() | Automatic |
| Node.js support | Native (18+) | Native |
| Browser support | All modern browsers | All browsers |
Use fetch() when:
- Bundle size matters
- You need response streaming
- You want
fetchLater()or priority hints - Your use cases are standard JSON API calls
Keep axios when:
- You need simple upload progress tracking (
onUploadProgress) - You have a complex interceptor chain that would be painful to replicate
- You’re working in an environment where the wrapper overhead isn’t worth it
The fetch() Feature Availability Reference
Feature | Chrome | Firefox | Safari | Status
────────────────────────────────────────────────────────────────────────
fetch() | ✅ 42+ | ✅ 39+ | ✅ 10+ | Baseline
AbortController | ✅ 66+ | ✅ 57+ | ✅ 12+ | Baseline
AbortSignal.timeout() | ✅ 103+ | ✅ 100+ | ✅ 16+ | Baseline
AbortSignal.any() | ✅ 116+ | ✅ 120+ | ✅ 17+ | Baseline
Response.body (streams) | ✅ 43+ | ✅ 65+ | ✅ 10+ | Baseline
Response.clone() | ✅ 40+ | ✅ 39+ | ✅ 10+ | Baseline
priority option | ✅ 101+ | ❌ | ❌ | Partial
fetchLater() | ✅ 126+ | ❌ | ❌ | Partial
Streaming request body | ✅ 105+ | ✅ 127+ | ❌ | Partial
Final Thoughts
fetch() in 2026 is not the bare-bones API that drove developers to axios in 2016. The cancellation story is clean with AbortSignal.timeout() and AbortSignal.any(). Response streaming is native and composable. fetchLater() replaces a whole category of fragile analytics implementations.
The wrapper in this post — 50 lines of TypeScript — provides typed responses, automatic error throwing, query parameter building, timeout handling, and combined abort signals. It covers everything most applications used axios for, at zero bundle cost.
The case for staying on axios remains for upload progress and complex interceptor chains. For everything else, the platform has caught up.
Check your bundle size. If you’re paying 13KB for axios and using three of its features, this is the week to write the wrapper.
