A tiny (~1.8KB g-zipped) wrapper built around fetch with an intuitive syntax.
f[ETCH] [WR]apper
Wretch 3.0 is now live π ! Check out the Migration Guide for upgrading from v2, and please have a look at the releases and the changelog after each update for new features and breaking changes.
And if you like the library please consider becoming a sponsor β€οΈ.
Features
wretch is a small wrapper around fetch designed to simplify the way to perform network requests and handle responses.
- πͺΆ Small - core is less than 1.8KB g-zipped
- π‘ Intuitive - lean API, handles errors, headers and (de)serialization
- π§ Immutable - every call creates a cloned instance that can then be reused safely
- π Modular - plug addons to add new features, and middlewares to intercept requests
- π§© Isomorphic - compatible with modern browsers, Node.js 22+, Deno and Bun
- π¦Ί Type safe - strongly typed, written in TypeScript
- β
Proven - fully covered by unit tests and widely used
- π Maintained - alive and well for many years
Table of Contents
Quick Start
npm i wretch
import wretch from "wretch"
const api = wretch("https://jsonplaceholder.typicode.com")
.options({ mode: "cors" })
const post = await api.get("/posts/1").json()
console.log(post.title)
const created = await api
.post({ title: "New Post", body: "Content", userId: 1 }, "/posts")
.json()
await api
.get("/posts/999")
.notFound(() => console.log("Post not found!"))
.json()
const text = await api.get("/posts/1").text()
const response = await api.get("/posts/1").res()
const blob = await api.get("/photos/1").blob()
Motivation
Because having to write a second callback to process a response body feels awkward.
Fetch needs a second callback to process the response body.
fetch("https://jsonplaceholder.typicode.com/posts/1")
.then(response => response.json())
.then(json => {
});
Wretch does it for you.
wretch("https://jsonplaceholder.typicode.com/posts/1")
.get()
.json(json => {
});
Because manually checking and throwing every request error code is tedious.
Fetch wonβt reject on HTTP error status.
fetch("https://jsonplaceholder.typicode.com/posts/1")
.then(response => {
if(!response.ok) {
if(response.status === 404) throw new Error("Not found")
else if(response.status === 401) throw new Error("Unauthorized")
else if(response.status === 418) throw new Error("I'm a teapot !")
else throw new Error("Other error")
}
else {}
})
.then(data => {})
.catch(error => { })
Wretch throws when the response is not successful and contains helper methods to handle common codes.
wretch("https://jsonplaceholder.typicode.com/posts/1")
.get()
.notFound(error => { })
.unauthorized(error => { })
.error(418, error => { })
.res(response => { })
.catch(error => { })
Because sending a json object should be easy.
With fetch you have to set the header, the method and the body manually.
fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ "hello": "world" })
}).then(response => {})
With wretch, you have shorthands at your disposal.
wretch("https://jsonplaceholder.typicode.com/posts")
.post({ "hello": "world" })
.res(response => { })
Because configuration should not rhyme with repetition.
A Wretch object is immutable which means that you can reuse previous instances safely.
const token = "MY_SECRET_TOKEN"
const externalApi = wretch("https://jsonplaceholder.typicode.com")
.auth(`Bearer ${token}`)
.options({ credentials: "include", mode: "cors" })
.resolve((w) => w.forbidden(error => { }));
const resource = await externalApi
.headers({ "If-Unmodified-Since": "Wed, 21 Oct 2015 07:28:00 GMT" })
.get("/posts/1")
.json(() => {});
externalApi
.url("/posts")
.post({ "Shiny new": "resource object" })
.json(() => {});
Installation
Package Manager
npm i wretch
<script> tag
The package contains multiple bundles depending on the format and feature set located under the /dist/bundle folder.
Bundle variants
π‘ If you pick the core bundle, then to plug addons you must import them separately from /dist/bundle/addons/[addonName].min.js
| Core features only | wretch.min.js |
| Core + all addons | wretch.all.min.js |
| ESM | .min.mjs |
| CommonJS | .min.cjs |
| UMD | .min.js |
<script src="https://unpkg.com/wretch"></script>
<script type="module">
import wretch from 'https://cdn.skypack.dev/wretch/dist/bundle/wretch.all.min.mjs'
</script>
Compatibility
Browsers
wretch@^3 is compatible with modern browsers only. For older browsers please use wretch@^1.
Node.js
Wretch is compatible with and tested in Node.js >= 22.
For older Node.js versions, please use wretch@^2.
Node.js 22+ includes native fetch support and all required Web APIs (FormData, URLSearchParams, AbortController, etc.) out of the box, so no polyfills are needed.
Deno
Works with Deno out of the box.
deno add npm:wretch
import wretch from "wretch";
const text = await wretch("https://httpbingo.org").get("/status/200").text();
console.log(text);
Bun
Works with Bun out of the box.
bun add wretch
import wretch from "wretch";
const text = await wretch("https://httpbingo.org").get("/status/200").text();
console.log(text);
Usage
Import
import wretch from "wretch"
const wretch = require("wretch")
window.wretch
Common Use Cases
const api = wretch("https://jsonplaceholder.typicode.com")
.auth("Bearer token")
.resolve(r => r.json())
const users = await api.get("/users")
const user = await api.post({ name: "John" }, "/users")
import ProgressAddon from "wretch/addons/progress"
import FormDataAddon from "wretch/addons/formData"
await wretch("https://httpbingo.org/post")
.addon([FormDataAddon, ProgressAddon()])
.formData({ file: file })
.post()
.progress((loaded, total) => console.log(`${(loaded/total*100).toFixed()}%`))
.json()
import { retry } from "wretch/middlewares"
const resilientApi = wretch()
.middlewares([retry({ maxAttempts: 3, retryOnNetworkError: true })])
interface User { id: number; name: string; email: string }
const user = await wretch("https://jsonplaceholder.typicode.com")
.get("/users/1")
.json<User>()
const api = wretch("https://httpbingo.org/basic-auth/user/pass")
.addon(BasicAuthAddon)
.resolve(w => w.unauthorized(async (error, req) => {
const newToken = await refreshToken()
return req
.basicAuth("user", "pass")
.unauthorized(e => {
console.log("Still unauthorized after token refresh");
throw e
})
.fetch()
.json()
}))
Custom Fetch Implementation
You can provide a custom fetch implementation using the .fetchPolyfill() method. This is useful for for a variety of use cases including mocking, adding logging, timing, or other custom behavior to all requests made through a wretch instance.
import wretch from "wretch"
const api = wretch("https://jsonplaceholder.typicode.com")
.fetchPolyfill((url, opts) => {
console.log('Fetching:', url)
console.time(url)
return fetch(url, opts).finally(() => {
console.timeEnd(url)
})
})
await api.get("/posts").json()
Converts a wretch instance into a fetch-like function, preserving all configuration (middlewares, catchers, headers, etc.). Useful for integrating wretch with libraries that expect a fetch signature.
const myFetch = wretch("https://jsonplaceholder.typicode.com")
.auth("Bearer token")
.catcher(401, (err) => console.log("Unauthorized"))
.toFetch()
const response = await myFetch("/users", { method: "GET" })
import { createClient } from "@apollo/client"
const client = createClient({
fetch: wretch().auth("Bearer token").toFetch()
})
Chaining
A high level overview of the successive steps that can be chained to perform a request and parse the result.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Request Chain β
β β
β wretch(url) ββ> .helper() ββ> .body() ββ> .httpMethod() β
β β β β β
β .headers() .json() .get() β
β .auth() .body() .post() β
β .options() .put() β
β .delete() β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
[ fetch() is called ]
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Response Chain β
β β
β .catcher() ββ> .responseType() ββ> Promise ββ> .then()/.catch()β
β β β β
β .notFound() .json() β
β .unauthorized() .text() β
β .error() .blob() β
β .res() β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Step-by-step breakdown:
wretch(baseUrl, baseOptions)
The "request" chain starts here.
.<helper method(s)>()
.<body type>()
.<http method>()
The "response" chain starts here.
Fetch is called after the request chain ends and before the response chain starts.
The request is on the fly and now it is time to chain catchers and finally call a response type handler.
.<catcher(s)>()
.<response type>()
From this point on, wretch returns a standard Promise.
.then(β¦)
.catch(β¦)
Concrete Example
Here's how the chaining works in practice:
await wretch("https://api.example.com")
.headers({ "X-Api-Key": "secret" })
.query({ limit: 10 })
.json({ name: "Alice", role: "admin" })
.post("/users")
.badRequest(err => console.log("Invalid"))
.unauthorized(err => console.log("No auth"))
.json(user => console.log(user))
Recipes
Looking for common patterns and solutions? Check out the Recipes Guide for practical examples covering:
- Error Handling - Parsing error response bodies, custom error types, global handlers
- TypeScript Patterns - Typing precomposed instances, reusable catchers
- File Uploads - Progress tracking, FormData handling
- Query Strings - Filtering undefined values
- Request Control - Combining timeouts with AbortControllers, aborting on errors
- Advanced Patterns - Token refresh & replay, schema validation, async polling
π‘ The API documentation is now autogenerated and hosted separately, click the links access it.
The default export is a factory function used to instantiate wretch.
import wretch from "wretch"
const api = wretch("http://domain.com/", { cache: "default" })
Helper Methods are used to configure the request and program actions.
Available methods: .url() Β· .options() Β· .headers() Β· .auth() Β· .accept() Β· .content() Β· .signal() Β· .toFetch() Β· .fetchPolyfill() Β· .catcher() Β· .catcherFallback() Β· .customError() Β· .defer() Β· .resolve() Β· .middlewares() Β· .addon() Β· .polyfills()
let api = wretch("http://domain.com/")
api = api
.url("/posts/1")
.headers({ "Cache-Control": "no-cache" })
.content("text/html")
Specify a body type if uploading data. Can also be added through the HTTP Method argument.
Available methods: .body() Β· .json()
let api = wretch("http://domain.com/")
api = api.body("<html><body><div/></body></html>")
Sets the HTTP method and sends the request.
Calling an HTTP method ends the request chain and returns a response chain.
You can pass optional url and body arguments to these methods.
Available methods: .get() Β· .post() Β· .put() Β· .patch() Β· .delete() Β· .head() Β· .opts()
const api = wretch("http://jsonplaceholder.typicode.com")
api.get("/posts");
api.post({ json: "body" }, "/posts");
api.url("/posts").get();
api.json({ json: "body" }).url("/posts").post();
NOTE: if the body argument is an Object it is assumed that it is a JSON payload and it will have the same behaviour as calling .json(body) unless the Content-Type header has been set to something else beforehand.
Catchers are optional, but if none are provided an error will still be thrown for http error codes and it will be up to you to catch it.
Available methods: .badRequest() Β· .unauthorized() Β· .forbidden() Β· .notFound() Β· .timeout() Β· .internalError() Β· .error() Β· .fetchError()
wretch("http://domain.com/resource")
.get()
.badRequest((err) => console.log(err.status))
.unauthorized((err) => console.log(err.status))
.forbidden((err) => console.log(err.status))
.notFound((err) => console.log(err.status))
.timeout((err) => console.log(err.status))
.internalError((err) => console.log(err.status))
.error(418, (err) => console.log(err.status))
.fetchError((err) => console.log(err))
.res();
The error passed to catchers is enhanced with additional properties.
type WretchError = Error & {
status: number;
response: WretchResponse;
url: string;
};
By default, error.message is set to the response body text (or statusText if body parsing fails).
Request Replay
The original request is passed along the error and can be used in order to
perform an additional request.
await wretch("https://httpbingo.org/basic-auth/user/pass")
.addon(BasicAuthAddon)
.basicAuth("user", "wrongpass")
.get()
.unauthorized(async (error, req) => {
const password = await wretch("https://httpbingo.org/base64/decode/cGFzcw==").get().text();
return req
.basicAuth("user", password)
.fetch()
.unauthorized((err) => { throw err })
.json();
})
.json()
.then(() => { });
Setting the final response body type ends the chain and returns a regular promise.
All these methods accept an optional callback, and will return a Promise
resolved with either the return value of the provided callback or the expected
type.
Available methods: .res() Β· .json() Β· .text() Β· .blob() Β· .arrayBuffer() Β· .formData()
const ENDPOINT = "https://jsonplaceholder.typicode.com/posts/1"
wretch(ENDPOINT)
.get()
.json()
.then(json => {
})
const json = await wretch(ENDPOINT).get().json()
wretch(ENDPOINT).get().json(json => "Hello world!").then(console.log)
If an error is caught by catchers, the response type handler will not be
called.
Addons
Addons are separate pieces of code that you can import and plug into wretch to add new features.
import FormDataAddon from "wretch/addons/formData"
import QueryStringAddon from "wretch/addons/queryString"
const w = wretch().addon([FormDataAddon, QueryStringAddon])
w.formData({ hello: "world" }).query({ check: true })
Typescript should also be fully supported and will provide completions.
https://user-images.githubusercontent.com/3428394/182319457-504a0856-abdd-4c1d-bd04-df5a061e515d.mov
Used to construct and append the query string part of the URL from an object.
import QueryStringAddon from "wretch/addons/queryString"
let w = wretch("http://example.com").addon(QueryStringAddon);
w = w.query({ a: 1, b: 2 });
w = w.query({ c: 3, d: [4, 5] });
w = w.query("five&six&seven=eight");
w = w.query({ reset: true }, { replace: true });
Adds a helper method to serialize a multipart/form-data body from an object.
import FormDataAddon from "wretch/addons/formData"
const form = {
duck: "Muscovy",
duckProperties: {
beak: {
color: "yellow",
},
legs: 2,
},
ignored: {
key: 0,
},
};
wretch("https://httpbingo.org/post").addon(FormDataAddon).formData(form, { recursive: ["ignored"] }).post();
Adds a method to serialize a application/x-www-form-urlencoded body from an object.
import FormUrlAddon from "wretch/addons/formUrl"
const form = { a: 1, b: { c: 2 } };
const alreadyEncodedForm = "a=1&b=%7B%22c%22%3A2%7D";
wretch("https://httpbingo.org/post").addon(FormUrlAddon).formUrl(form).post();
wretch("https://httpbingo.org/post").addon(FormUrlAddon).formUrl(alreadyEncodedForm).post();
Adds the ability to abort requests and set timeouts using AbortController and signals under the hood.
import AbortAddon from "wretch/addons/abort"
Use cases :
const [c, w] = wretch("https://httpbingo.org/get")
.addon(AbortAddon())
.get()
.onAbort((_) => console.log("Aborted !"))
.controller();
w.text((_) => console.log("should never be called"));
c.abort();
const controller = new AbortController();
wretch("https://httpbingo.org/get")
.addon(AbortAddon())
.signal(controller)
.get()
.onAbort((_) => console.log("Aborted !"))
.text((_) => console.log("should never be called"));
controller.abort();
wretch("https://httpbingo.org/delay/2")
.addon(AbortAddon())
.get()
.setTimeout(1000)
.onAbort(_ => {
console.log("Request timed out")
})
.json(_ => {
console.log("Response received in time")
})
Adds the ability to set the Authorization header for the basic authentication scheme without the need to manually encode the username/password.
Also, allows using URLs with wretch that contain credentials, which would otherwise throw an error.
import BasicAuthAddon from "wretch/addons/basicAuth"
const user = "user"
const pass = "pass"
wretch("https://httpbingo.org/get")
.addon(BasicAuthAddon)
.basicAuth(user, pass)
.get()
wretch(`https://${user}:${pass}@httpbingo.org/basic-auth/${user}/${pass}`)
.addon(BasicAuthAddon)
.get()
Adds the ability to monitor progress when downloading or uploading.
Compatible with all platforms implementing the TransformStream WebAPI.
Download progress:
import ProgressAddon from "wretch/addons/progress"
await wretch("https://httpbingo.org/bytes/5000")
.addon(ProgressAddon())
.get()
.progress((loaded, total) => {
console.log(`Download: ${(loaded / total * 100).toFixed(0)}%`)
})
.blob()
Upload progress:
import ProgressAddon from "wretch/addons/progress"
import FormDataAddon from "wretch/addons/formData"
const formData = new FormData()
formData.append('file', file)
wretch("https://httpbingo.org/post")
.addon([ProgressAddon(), FormDataAddon])
.onUpload((loaded, total) => {
console.log(`Upload: ${(loaded / total * 100).toFixed(0)}%`)
})
.post(formData)
.res()
Note for browsers: Upload progress requires HTTPS (HTTP/2) in Chrome/Chromium and doesn't work in Firefox due to streaming limitations. Works fully in Node.js.
Adds the ability to measure requests using the Performance Timings API.
Uses the Performance API (browsers & Node.js) to expose timings related to the underlying request.
π‘ Make sure to follow the additional instructions in the documentation to setup Node.js if necessary.
Middlewares
Middlewares are functions that can intercept requests before being processed by
Fetch. Wretch includes a helper to help replicate the
middleware style.
import wretch from "wretch"
import { retry, dedupe } from "wretch/middlewares"
const w = wretch().middlewares([retry(), dedupe()])
π‘ The following middlewares were previously provided by the wretch-middlewares package.
Retries a request multiple times in case of an error (or until a custom condition is true).
π‘ By default, the request will be retried only for server errors (5xx) and other non-successful responses, but not for client errors (4xx).
until: (response, error) => !!response && response.ok
import wretch from 'wretch'
import { retry } from 'wretch/middlewares'
wretch().middlewares([
retry({
delayTimer: 500,
delayRamp: (delay, nbOfAttempts) => delay * nbOfAttempts,
maxAttempts: 10,
until: (response, error) => !!response && (response.ok || (response.status >= 400 && response.status < 500)),
onRetry: undefined,
retryOnNetworkError: false,
resolveWithLatestResponse: false
})
])
wretch().middlewares([
retry({
until: response =>
response?.clone().json().then(body =>
body.field === 'something'
) || false
})
])
Prevents having multiple identical requests on the fly at the same time.
import wretch from 'wretch'
import { dedupe } from 'wretch/middlewares'
wretch().middlewares([
dedupe({
skip: (url, opts) => opts.skipDedupe || opts.method !== 'GET',
key: (url, opts) => opts.method + '@' + url,
resolver: response => response.clone()
})
])
A throttling cache which stores and serves server responses for a certain amount of time.
import wretch from 'wretch'
import { throttlingCache } from 'wretch/middlewares'
wretch().middlewares([
throttlingCache({
throttle: 1000,
skip: (url, opts) => opts.skipCache || opts.method !== 'GET',
key: (url, opts) => opts.method + '@' + url,
clear: (url, opts) => false,
invalidate: (url, opts) => null,
condition: response => response.ok,
flagResponseOnCacheHit: '__cached'
})
])
Delays the request by a specific amount of time.
import wretch from 'wretch'
import { delay } from 'wretch/middlewares'
wretch().middlewares([
delay(1000)
])
Writing a Middleware
Basically a Middleware is a function having the following signature :
type Middleware = (options?: { [key: string]: any }) => ConfiguredMiddleware;
type ConfiguredMiddleware = (next: FetchLike) => FetchLike;
type FetchLike = (
url: string,
opts: WretchOptions,
) => Promise<WretchResponse>;
Context
If you need to manipulate data within your middleware and expose it for later
consumption, a solution could be to pass a named property to the wretch options
(suggested name: context).
Your middleware can then take advantage of that by mutating the object
reference.
const contextMiddleware = (next) =>
(url, opts) => {
if (opts.context) {
opts.context.property = "anything";
}
return next(url, opts);
};
const context = {};
const res = await wretch("https://httpbingo.org/get")
.options({ context })
.middlewares([contextMiddleware])
.get()
.res();
console.log(context.property);
Advanced examples
Β π Show me the code
const delayMiddleware = delay => next => (url, opts) => {
return new Promise(res => setTimeout(() => res(next(url, opts)), delay))
}
const shortCircuitMiddleware = () => next => (url, opts) => {
const response = new Response(url)
return Promise.resolve(response)
}
const logMiddleware = () => next => (url, opts) => {
console.log(opts.method + "@" + url)
return next(url, opts)
}
const cacheMiddleware = (throttle = 0) => {
const cache = new Map()
const inflight = new Map()
const throttling = new Set()
return next => (url, opts) => {
const key = opts.method + "@" + url
if(!opts.noCache && throttling.has(key)) {
if(cache.has(key))
return Promise.resolve(cache.get(key).clone())
else if(inflight.has(key)) {
return new Promise((resolve, reject) => {
inflight.get(key).push([resolve, reject])
})
}
}
if(!inflight.has(key))
inflight.set(key, [])
if(throttle && !throttling.has(key)) {
throttling.add(key)
setTimeout(() => { throttling.delete(key) }, throttle)
}
return next(url, opts)
.then(_ => {
cache.set(key, _.clone())
inflight.get(key)?.forEach((([resolve, reject]) => resolve(_.clone())))
inflight.delete(key)
return _
})
.catch(_ => {
inflight.get(key)?.forEach(([resolve, reject]) => reject(_))
inflight.delete(key)
throw _
})
}
}
const cache = cacheMiddleware(1000)
wretch("https://httpbingo.org/get").middlewares([cache]).get()
wretch("https://httpbingo.org/get").middlewares([
logMiddleware(),
delayMiddleware(1000),
shortCircuitMiddleware()
]).get().text(text => console.log(text))
const wretchCache = wretch("https://httpbingo.org").middlewares([cacheMiddleware(500)])
const printResource = (url, timeout = 0) => {
return new Promise(resolve => setTimeout(async () => {
wretchCache.url(url).get().notFound(console.error).text(resource => {
console.log(resource)
resolve(resource)
})
}, timeout))
}
const resourceUrl = "/base64/decode/YWVhY2YyYWYtODhlNi00ZjgxLWEwYjAtNzdhMTIxNTA0Y2E4"
await Promise.all(Array.from({ length: 10 }).flatMap(() =>
[
printResource(resourceUrl),
printResource(resourceUrl, 200),
printResource(resourceUrl, 700)
]
))
Limitations
It seems like using wretch in a Cloudflare Worker environment is not possible out of the box, as the Cloudflare Response implementation does not implement the type property and throws an error when trying to access it.
Please check the issue #159 for more information.
Workaround
The following middleware should fix the issue (thanks @jimmed π):
wretch().middlewares([
(next) => async (url, opts) => {
const response = await next(url, opts);
try {
Reflect.get(response, "type", response);
} catch (error) {
Object.defineProperty(response, "type", {
get: () => "default",
});
}
return response;
},
])
The Request object from the Fetch API uses the Headers class to store headers under the hood.
This class is case-insensitive, meaning that setting both will actually appends the value to the same key:
const headers = new Headers();
headers.append("Accept", "application/json");
headers.append("accept", "application/json");
headers.forEach((value, key) => console.log(key, value));
When using wretch, please be mindful of this limitation and avoid setting the same header multiple times with a different case:
wretch("https://httpbingo.org/post")
.headers({ "content-type": "application/json" })
.json({ foo: "bar" })
Please check the issue #80 for more information.
Workaround
You can use the following middleware to deduplicate headers (thanks @jimmed π):
export const manipulateHeaders =
callback => next => (url, { headers, ...opts }) => {
const nextHeaders = callback(new Headers(headers))
return next(url, { ...opts, headers: nextHeaders })
}
export const dedupeHeaders = (dedupeHeaderLogic = {}) => {
const deduperMap = new Map(
Object.entries(dedupeHeaderLogic).map(([k, v]) => [k.toLowerCase(), v]),
)
const dedupe = key =>
deduperMap.get(key.toLowerCase()) ?? (values => new Set(values))
return manipulateHeaders((headers) => {
Object.entries(headers.raw()).forEach(([key, values]) => {
const deduped = Array.from(dedupe(key)(values))
headers.delete(key)
deduped.forEach((value, index) =>
headers[index ? 'append' : 'set'](key.toLowerCase(), value),
)
})
return headers
})
}
wretch().middlewares([dedupeHeaders()])
wretch().middlewares([
dedupeHeaders({
Accept: (values) => values.filter(v => v !== '*/*')
})
])
Migration Guides
Comprehensive migration guides are available for upgrading between major versions:
License
MIT