Migrating from axios
parcely is designed so axios users feel at home immediately. This guide shows every common axios pattern alongside its parcely equivalent, and includes a full feature-mapping table at the bottom.
Differences from axios
parcely is a different library — not a drop-in replacement. These are the behavioural and surface-level differences you'll hit first, and the reasoning for each.
ESM-only
parcely ships ESM only. If your project is CommonJS, use dynamic import() or add "type": "module" to your package.json. See the installation guide for details.
Why: avoids dual-format maintenance, keeps tree-shaking clean, and Node 20+ / Bun / Deno / modern browsers all support ESM first-class.
No built-in retry
axios needs axios-retry; parcely has @parcely/retry for the same feature set. Until you add it, a response error interceptor is three lines:
http.interceptors.response.use(undefined, async (err) => {
if (shouldRetry(err)) return http.request({ ...err.config, _retry: true })
throw err
})
Why: retry is policy, not mechanism. Keeping it out of core means users pick exactly the backoff / jitter / idempotency rules they want.
No transformRequest / transformResponse
Use request / response interceptors. Same expressive power, smaller API surface.
No paramsSerializer customization
parcely always uses URLSearchParams. For axios-style bracket notation on arrays in request bodies, use formDataSerializer: 'brackets' on the config. For custom URL query serialization, pre-serialize the params yourself and pass them as a string via a request interceptor.
Why: paramsSerializer has been a CVE surface in other clients (unescaped user input flowing into URLs). A single, predictable serializer closes that door.
No XMLHttpRequest fallback
parcely uses global fetch exclusively. In environments where fetch is unavailable (ancient browsers, jsdom without a polyfill), parcely will not work. axios ships both XHR and http adapters.
Why: fetch is universally available on every runtime we target (Node 20+, Bun, Deno, modern browsers). The XHR adapter in axios is ~30% of its bundle size and adds another surface for bugs.
Upload progress has browser limitations
parcely implements upload progress via ReadableStream request bodies with duplex: 'half'. This works reliably in Node (undici) and Chromium 105+. In Safari and Firefox, fetch streaming request bodies aren't yet supported — onUploadProgress fires a single terminal callback at loaded === total on completion, with a one-shot console.warn in dev mode. axios uses XHR, which has broad upload-progress support but doesn't stream.
Why: the streaming API is the right shape; browser support is catching up; the fallback is honest rather than silently broken.
Error shape
HttpError vs axios's AxiosError:
| axios | parcely |
|---|---|
err.response | err.response (when we got bytes back) |
err.request | ✗ (raw Request not exposed — see threat model) |
err.config | err.config (with sensitive headers redacted) |
err.code — loose string | err.code — discriminated union, exhaustive switch works |
err.isAxiosError | err instanceof HttpError or isHttpError(err) |
err.toJSON() | err.toJSON() — structured-logging friendly |
Response headers are Headers, not a plain object
axios gives you res.headers as a Record<string, string>. parcely gives you a native Headers instance. Use res.headers.get('x-thing') instead of res.headers['x-thing'].
Why: preserves casing normalization rules and multi-value semantics that plain objects mangle.
Cookies are not managed automatically in Node
axios has optional withCredentials / cookie-jar integration for Node. parcely doesn't — if you need cookie jars in Node, use a tough-cookie-backed interceptor. Browser cookies work normally via the fetch credentials option.
Creating a client
- axios
- parcely
import axios from 'axios'
const http = axios.create({
baseURL: 'https://api.example.com',
headers: { Accept: 'application/json' },
timeout: 5000,
})
import { createClient } from '@parcely/core'
const http = createClient({
baseURL: 'https://api.example.com',
headers: { Accept: 'application/json' },
timeout: 5000,
})
GET request
- axios
- parcely
const { data, status, headers } = await http.get('/users/me')
const { data, status, headers } = await http.get('/users/me')
Identical syntax.
POST with JSON body
- axios
- parcely
const { data } = await http.post('/users', { name: 'Mickey' })
const { data } = await http.post('/users', { name: 'Mickey' })
Identical syntax. Plain objects are JSON-serialised automatically.
Query parameters
- axios
- parcely
await http.get('/search', { params: { q: 'hello', page: 1 } })
await http.get('/search', { params: { q: 'hello', page: 1 } })
Request headers
- axios
- parcely
await http.get('/data', {
headers: { Authorization: 'Bearer tok' },
})
await http.get('/data', {
headers: { Authorization: 'Bearer tok' },
})
Error handling
- axios
- parcely
import axios from 'axios'
try {
await http.get('/fail')
} catch (err) {
if (axios.isAxiosError(err)) {
console.log(err.response?.status)
console.log(err.code) // e.g. 'ECONNABORTED'
}
}
import { isHttpError } from '@parcely/core'
try {
await http.get('/fail')
} catch (err) {
if (isHttpError(err)) {
console.log(err.status) // number | undefined
console.log(err.code) // e.g. 'ERR_HTTP_STATUS'
console.log(err.response?.data) // parsed response body
}
}
Key differences:
- parcely uses
isHttpError()instead ofaxios.isAxiosError(). - Error codes are different: parcely uses descriptive codes like
ERR_HTTP_STATUS,ERR_TIMEOUT,ERR_NETWORK,ERR_ABORTED,ERR_VALIDATION,ERR_PARSE,ERR_TOO_MANY_REDIRECTS,ERR_DISALLOWED_PROTOCOL,ERR_DISALLOWED_HEADER,ERR_ABSOLUTE_URL,ERR_CRLF_INJECTION. err.statusis a top-level property (not only nested inerr.response).
Interceptors
- axios
- parcely
http.interceptors.request.use(
(config) => {
config.headers['X-Trace'] = crypto.randomUUID()
return config
},
(err) => Promise.reject(err),
)
http.interceptors.response.use(
(response) => response,
(err) => { console.error(err); return Promise.reject(err) },
)
http.interceptors.request.use(
(config) => ({
...config,
headers: { ...config.headers, 'X-Trace': crypto.randomUUID() },
}),
(err) => { throw err },
)
http.interceptors.response.use(
(response) => response,
(err) => { console.error(err); throw err },
)
Key difference: parcely configs are plain objects -- spread and return a new object rather than mutating. Error handlers can throw instead of return Promise.reject().
Cancellation
- axios
- parcely
const controller = new AbortController()
await http.get('/data', { signal: controller.signal })
controller.abort()
const controller = new AbortController()
await http.get('/data', { signal: controller.signal })
controller.abort()
Identical. parcely does not support the legacy CancelToken API.
Timeouts
- axios
- parcely
await http.get('/slow', { timeout: 3000 })
await http.get('/slow', { timeout: 3000 })
In parcely, timeout is combined with a user-provided signal via AbortSignal.any, so both can coexist cleanly.
File upload with FormData
- axios
- parcely
const form = new FormData()
form.append('avatar', file)
await http.post('/upload', form)
const form = new FormData()
form.set('avatar', file)
await http.post('/upload', form)
Upload progress
- axios
- parcely
await http.post('/upload', form, {
onUploadProgress: (e) => console.log(e.loaded, e.total),
})
await http.post('/upload', form, {
onUploadProgress: ({ loaded, total, percent }) =>
console.log(loaded, total, percent),
})
parcely progress events include a percent field (0--100) when the total is known.
Download progress
- axios
- parcely
const { data } = await http.get('/big-file', {
onDownloadProgress: (e) => console.log(e.loaded),
})
const { data } = await http.get('/big-file', {
onDownloadProgress: ({ loaded, total, percent }) =>
console.log(loaded, total, percent),
})
Response types
- axios
- parcely
const { data } = await http.get('/file', { responseType: 'arraybuffer' })
const { data } = await http.get('/file', { responseType: 'arraybuffer' })
parcely supports 'json' (default), 'text', 'arraybuffer', 'blob', and 'stream' (returns the un-consumed Web ReadableStream). Axios 'document' (browser DOM parsing) is not provided. Axios 'stream' returns a Node Readable; parcely 'stream' returns a Web ReadableStream — use Readable.fromWeb() if you need the Node form.
Runtime validation (new in parcely)
parcely adds a validate option with no axios equivalent:
import { createClient } from '@parcely/core'
import { z } from 'zod'
const http = createClient({ baseURL: 'https://api.example.com' })
const User = z.object({ id: z.string(), name: z.string() })
const { data } = await http.get('/users/me', { validate: User })
// data is typed as { id: string; name: string }
Validators can be Zod schemas, Valibot schemas, ArkType types, objects with a .parse() method, or plain functions.
Feature-mapping table
| axios config key | parcely equivalent | Notes |
|---|---|---|
baseURL | baseURL | Accepts string | URL. When set, absolute URLs in url are rejected by default (SSRF protection). |
url | url | Relative path appended to baseURL. |
method | method | Same ('GET', 'POST', etc.). |
headers | headers | Accepts Record<string, string>, [string, string][], or Headers. |
params | params | Record<string, unknown>. Arrays and objects are serialised to query string. |
data | body (second argument to .post(), .put(), .patch()) | Renamed to body in the config object. Method helpers accept it as the second positional argument. |
timeout | timeout | Milliseconds. Combined with user signal via AbortSignal.any. |
signal | signal | Standard AbortSignal. |
responseType | responseType | 'json' | 'text' | 'arraybuffer' | 'blob' | 'stream' (Web ReadableStream). No 'document'. |
maxRedirects | maxRedirects | Default 5. Manual redirect loop with cross-origin header stripping. |
onUploadProgress | onUploadProgress | (event: ProgressEvent) => void where ProgressEvent = { loaded, total?, percent? }. |
onDownloadProgress | onDownloadProgress | Same shape as upload progress. |
cancelToken | Not provided | Use standard AbortController / signal instead. CancelToken is a legacy axios API. |
transformRequest | Not provided | Use a request interceptor instead. Interceptors are more composable and testable. |
transformResponse | Not provided | Use a response interceptor or validate for runtime type checking. |
adapter | Not provided | parcely always uses fetch. For testing, stub globalThis.fetch. |
auth | Not provided as config | Use headers: { Authorization: 'Bearer ...' } or @parcely/auth-token. |
xsrfCookieName / xsrfHeaderName | Not provided | XSRF token handling is better done at the framework level (e.g., meta tags). Use a request interceptor if needed. |
maxContentLength / maxBodyLength | Not provided | Not a v1 feature. Can be implemented via a response interceptor. |
decompress | Not provided | fetch handles decompression automatically. |
httpAgent / httpsAgent | tls | Node-only TLS config ({ rejectUnauthorized?, ca? }). Uses undici dispatcher under the hood. |
proxy | Not provided | Configure at the environment/runtime level. |
validateStatus | Not provided | Non-2xx responses always throw HttpError with code ERR_HTTP_STATUS. Use a response interceptor to customise. |
paramsSerializer | Not provided | Built-in serialiser handles arrays and nested objects. |
formSerializer | formDataSerializer | Controls how arrays and nested objects are serialised in auto-FormData: 'brackets' (default), 'indices', or 'repeat'. |
withCredentials | Not provided | Set credentials: 'include' via a request interceptor if needed. parcely does not wrap the fetch credentials option. |
withXSRFToken | Not provided | Use a request interceptor to read cookies and set headers. |
| N/A | validate | New. Runtime response validation via Standard Schema, .parse(), or function. |
| N/A | allowAbsoluteUrls | New. When false (default when baseURL is set), rejects absolute URLs. Prevents SSRF. |
| N/A | allowedProtocols | New. Default ['http:', 'https:']. Blocks file:, data:, javascript:. |
| N/A | allowedRequestHeaders | New. Opt-in allowlist of permitted request header names. |
| N/A | sensitiveHeaders | New. Headers stripped on cross-origin redirects. |
| N/A | followRedirects | New. Toggle manual redirect following (default true). |
| N/A | formDataSerializer | New. 'brackets' | 'indices' | 'repeat' for auto-FormData nested-key serialisation. |
| N/A | tls | New. Node-only { rejectUnauthorized?, ca? }. |
Breaking differences summary
- Error shape.
HttpErrorhas acodefield with descriptive values (ERR_HTTP_STATUS,ERR_TIMEOUT, etc.) instead of axios's codes. UseisHttpError()instead ofaxios.isAxiosError(). - Envelope keys. The response envelope has
data,status,statusText,headers, andconfig. Norequestproperty. - No
transformRequest/transformResponse. Use interceptors instead. - No
CancelToken. UseAbortController(which axios also supports). - Body parameter. The config property is
body(notdata). Method helpers (.post(),.put(),.patch()) still accept it as the second positional argument. - Headers are immutable. Interceptors should spread and return a new config rather than mutating
config.headers. - Secure defaults. Absolute URLs are rejected when
baseURLis set. Cross-origin redirects strip sensitive headers. These are opt-out if needed.