Skip to main content

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:

axiosparcely
err.responseerr.response (when we got bytes back)
err.request✗ (raw Request not exposed — see threat model)
err.configerr.config (with sensitive headers redacted)
err.code — loose stringerr.code — discriminated union, exhaustive switch works
err.isAxiosErrorerr 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

import axios from 'axios'

const http = axios.create({
baseURL: 'https://api.example.com',
headers: { Accept: 'application/json' },
timeout: 5000,
})

GET request

const { data, status, headers } = await http.get('/users/me')

Identical syntax.

POST with JSON body

const { data } = await http.post('/users', { name: 'Mickey' })

Identical syntax. Plain objects are JSON-serialised automatically.

Query parameters

await http.get('/search', { params: { q: 'hello', page: 1 } })

Request headers

await http.get('/data', {
headers: { Authorization: 'Bearer tok' },
})

Error handling

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'
}
}

Key differences:

  • parcely uses isHttpError() instead of axios.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.status is a top-level property (not only nested in err.response).

Interceptors

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) },
)

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

const controller = new AbortController()
await http.get('/data', { signal: controller.signal })
controller.abort()

Identical. parcely does not support the legacy CancelToken API.

Timeouts

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

const form = new FormData()
form.append('avatar', file)
await http.post('/upload', form)

Upload progress

await http.post('/upload', form, {
onUploadProgress: (e) => console.log(e.loaded, e.total),
})

parcely progress events include a percent field (0--100) when the total is known.

Download progress

const { data } = await http.get('/big-file', {
onDownloadProgress: (e) => console.log(e.loaded),
})

Response types

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 keyparcely equivalentNotes
baseURLbaseURLAccepts string | URL. When set, absolute URLs in url are rejected by default (SSRF protection).
urlurlRelative path appended to baseURL.
methodmethodSame ('GET', 'POST', etc.).
headersheadersAccepts Record<string, string>, [string, string][], or Headers.
paramsparamsRecord<string, unknown>. Arrays and objects are serialised to query string.
databody (second argument to .post(), .put(), .patch())Renamed to body in the config object. Method helpers accept it as the second positional argument.
timeouttimeoutMilliseconds. Combined with user signal via AbortSignal.any.
signalsignalStandard AbortSignal.
responseTyperesponseType'json' | 'text' | 'arraybuffer' | 'blob' | 'stream' (Web ReadableStream). No 'document'.
maxRedirectsmaxRedirectsDefault 5. Manual redirect loop with cross-origin header stripping.
onUploadProgressonUploadProgress(event: ProgressEvent) => void where ProgressEvent = { loaded, total?, percent? }.
onDownloadProgressonDownloadProgressSame shape as upload progress.
cancelTokenNot providedUse standard AbortController / signal instead. CancelToken is a legacy axios API.
transformRequestNot providedUse a request interceptor instead. Interceptors are more composable and testable.
transformResponseNot providedUse a response interceptor or validate for runtime type checking.
adapterNot providedparcely always uses fetch. For testing, stub globalThis.fetch.
authNot provided as configUse headers: { Authorization: 'Bearer ...' } or @parcely/auth-token.
xsrfCookieName / xsrfHeaderNameNot providedXSRF token handling is better done at the framework level (e.g., meta tags). Use a request interceptor if needed.
maxContentLength / maxBodyLengthNot providedNot a v1 feature. Can be implemented via a response interceptor.
decompressNot providedfetch handles decompression automatically.
httpAgent / httpsAgenttlsNode-only TLS config ({ rejectUnauthorized?, ca? }). Uses undici dispatcher under the hood.
proxyNot providedConfigure at the environment/runtime level.
validateStatusNot providedNon-2xx responses always throw HttpError with code ERR_HTTP_STATUS. Use a response interceptor to customise.
paramsSerializerNot providedBuilt-in serialiser handles arrays and nested objects.
formSerializerformDataSerializerControls how arrays and nested objects are serialised in auto-FormData: 'brackets' (default), 'indices', or 'repeat'.
withCredentialsNot providedSet credentials: 'include' via a request interceptor if needed. parcely does not wrap the fetch credentials option.
withXSRFTokenNot providedUse a request interceptor to read cookies and set headers.
N/AvalidateNew. Runtime response validation via Standard Schema, .parse(), or function.
N/AallowAbsoluteUrlsNew. When false (default when baseURL is set), rejects absolute URLs. Prevents SSRF.
N/AallowedProtocolsNew. Default ['http:', 'https:']. Blocks file:, data:, javascript:.
N/AallowedRequestHeadersNew. Opt-in allowlist of permitted request header names.
N/AsensitiveHeadersNew. Headers stripped on cross-origin redirects.
N/AfollowRedirectsNew. Toggle manual redirect following (default true).
N/AformDataSerializerNew. 'brackets' | 'indices' | 'repeat' for auto-FormData nested-key serialisation.
N/AtlsNew. Node-only { rejectUnauthorized?, ca? }.

Breaking differences summary

  1. Error shape. HttpError has a code field with descriptive values (ERR_HTTP_STATUS, ERR_TIMEOUT, etc.) instead of axios's codes. Use isHttpError() instead of axios.isAxiosError().
  2. Envelope keys. The response envelope has data, status, statusText, headers, and config. No request property.
  3. No transformRequest / transformResponse. Use interceptors instead.
  4. No CancelToken. Use AbortController (which axios also supports).
  5. Body parameter. The config property is body (not data). Method helpers (.post(), .put(), .patch()) still accept it as the second positional argument.
  6. Headers are immutable. Interceptors should spread and return a new config rather than mutating config.headers.
  7. Secure defaults. Absolute URLs are rejected when baseURL is set. Cross-origin redirects strip sensitive headers. These are opt-out if needed.