This document describes the design of this package: a dependency-free, fetch-native HTTP client for modern JavaScript and TypeScript runtimes.
The purpose of this package is not to replace the web platform. It exists to make the platform easier to use well by providing a small set of carefully chosen conveniences:
- reusable client instances with shared defaults
- base URL handling
- query parameter serialization
- JSON request/response ergonomics
- timeout support
- a consistent error model
- explicit request/response lifecycle hooks
- conservative retry support
The package is intentionally small in scope. It prioritizes:
- predictability
- auditability
- explicitness
- strong defaults
- minimal supply-chain risk
This document is the source of truth for architectural intent and behavioral guarantees.
This package is built on top of native platform primitives:
fetchRequestResponseHeadersURLAbortController
It should feel like native fetch with a disciplined convenience layer, not like a separate transport system with unrelated rules.
The package must have zero runtime dependencies.
This is a core design constraint, not an aspirational goal. It reduces supply-chain risk, simplifies auditing, and keeps the implementation understandable.
Development dependencies are acceptable where necessary for building, linting, and testing, but runtime behavior must not depend on third-party packages.
The public API should remain intentionally small. New features must justify their existence in terms of:
- developer value
- conceptual simplicity
- maintenance burden
- security impact
- testability
- auditability
If a feature adds more complexity than clarity, it should not be added.
Convenience is valuable only when it remains understandable.
The library must avoid:
- hidden behavior
- surprising defaults
- ambiguous option interactions
- magical transformations
- silent fallbacks that conceal errors
It is better to be explicit and slightly stricter than to be flexible in ways that create uncertainty.
This package targets modern environments with native fetch support.
It does not attempt to support older browsers or legacy Node.js environments through runtime shims or polyfills.
For this package, security primarily means:
- minimal attack surface
- no runtime dependencies
- no lifecycle scripts
- no hidden telemetry
- no dangerous defaults
- no implicit behavior that makes misuse easier
- a release process designed for trust and auditability
TypeScript support is part of the product.
The package should offer strong types for:
- request options
- client defaults
- hook contexts
- error classes
- response modes
Types should improve correctness and developer experience without pretending to provide guarantees the runtime cannot enforce.
Version 1 includes the following capabilities:
- one-off requests
- reusable client instances
- HTTP method helpers
- client extension via merged defaults
- base URL support
- deterministic header merging
- query parameter serialization
- JSON request convenience
- response parsing modes
- request timeouts
- explicit lifecycle hooks
- typed error hierarchy
- optional conservative retry support
The following are explicitly out of scope for version 1:
- support for legacy runtimes without native
fetch - adapter systems
- plugin ecosystems in core
- upload progress abstractions
- download progress abstractions
- cookie jar management
- automatic caching
- transform pipelines
- broad serialization frameworks
- XSRF-specific magic
- automatic retries for unsafe methods by default
- polyfills or environment shims
- hidden diagnostics or telemetry
These features are excluded intentionally to preserve simplicity and clarity.
This package is designed for modern JavaScript runtimes that provide native fetch and related web platform APIs.
The minimum supported Node.js version should be declared in package.json under engines. The implementation should target only runtimes that satisfy that requirement.
The package is designed as a modern module-first library. Export behavior must be explicit and restricted through package.json export maps.
The package should not expose internal implementation paths.
The public API should be small and unsurprising.
The package exposes two primary entry points:
- a one-off request function
- a reusable client factory
Conceptually:
request()is for direct, stateless usecreateClient()is for shared defaults and repeated calls
The client produced by createClient() should support:
request()get()post()put()patch()delete()head()options()extend()
The public API should:
- be easy to learn in one sitting
- map closely to native fetch semantics
- avoid an oversized configuration object with poorly defined interactions
- remain stable over time
The public API should not:
- attempt feature parity with Axios
- expose internal machinery unnecessarily
- create multiple competing ways to do the same thing
- hide important platform behavior behind vague abstractions
The request function is the core execution path.
Responsibilities:
- validate request options
- resolve the final URL
- merge headers
- normalize the request body
- apply timeout and abort semantics
- construct a
Request - run lifecycle hooks
- perform the network call using
fetch - classify failures consistently
- parse the response according to configured behavior
A client instance represents a reusable set of defaults.
Responsibilities:
- store shared defaults
- expose HTTP method helpers
- merge request-level options with client defaults deterministically
- allow safe extension through
extend()
A client instance should be lightweight and immutable in behavior from the perspective of consumers. Mutating shared defaults after creation should be avoided.
Hooks are explicit lifecycle points for cross-cutting behavior.
Supported lifecycle points:
beforeRequestafterResponseonError
Hooks provide limited, well-defined extension points without turning the package into a general middleware framework.
Errors are a first-class part of the design.
The package must distinguish clearly between:
- invalid configuration
- network failure
- timeout
- external abort
- HTTP failure
- parse failure
This distinction is important for correctness, observability, and developer ergonomics.
The request lifecycle is defined as follows:
- Merge client defaults and request-level options
- Validate the resulting configuration
- Resolve the final URL
- Build final headers
- Normalize the request body
- Execute
beforeRequesthooks - Create the final
Request - Apply timeout and abort configuration
- Perform
fetch - Execute
afterResponsehooks - Classify non-success HTTP responses
- Parse the response according to
responseType - Return parsed result or raw
Response - On failure, execute
onErrorhooks with the classified failure context - Re-throw the classified failure
This order is intentional and should remain stable unless there is a strong reason to change it.
Configuration should be explicit, narrow, and validated.
The library should prefer a small number of clear options over a large number of loosely interacting options.
The request configuration supports only the options needed for the core use cases. These include:
- HTTP method
- headers
- query parameters
- raw body
- JSON body helper
- timeout
- signal
- response type
- retry configuration
- optional request-scoped hooks
- optional custom JSON parser
Client defaults may include:
- base URL
- default headers
- default timeout
- default response type
- default retry behavior
- default hooks
- default JSON parser
Validation must occur before the request is executed.
Invalid configurations must fail fast with a configuration error.
Examples of invalid configurations include:
- both
bodyandjsonprovided - negative timeout
- malformed base URL
- unsupported response type
- invalid retry values
Strict validation is desirable. Silent coercion should be avoided unless it is trivial and unsurprising.
Merge behavior is one of the most important parts of the design. It must be deterministic and well documented.
When merging client defaults and request-level options:
- request-level options override client defaults
- request-scoped hooks are appended after client hooks
- request headers override default headers when keys collide
This allows shared defaults while preserving per-request control.
Headers are merged deterministically using the platform Headers model.
Rules:
- client default headers are applied first
- request-specific headers are applied second
- later values replace earlier values for the same normalized header name
The package should rely on the behavior of Headers for normalization wherever practical rather than implementing its own ad hoc casing logic.
Hooks are merged by concatenation, not replacement.
Order:
- client hooks first
- request hooks second
This allows request-level hooks to override or refine behavior established by client-level hooks.
Retry configuration follows the same precedence model:
- request-level retry configuration overrides client-level retry configuration
If retry is explicitly disabled at request level, request-level disablement wins.
If a request input is an absolute URL, it must not be modified by baseURL.
If a request input is relative and baseURL is provided, the final URL is resolved against that base using the URL constructor.
The package should not implement custom URL concatenation logic beyond what is needed to produce correct and predictable results.
The final request URL is determined by combining:
- the request input
- optional
baseURL - optional query parameters
Rules:
- absolute request input takes precedence over
baseURL - relative request input is resolved against
baseURL - if no
baseURLexists, relative request input is invalid and must fail withConfigError
Query serialization should be conservative and easy to understand.
Supported values:
- string
- number
- boolean
- null
- arrays of the above
undefinedas “omit the key”
Unsupported structures, such as deeply nested objects, are intentionally out of scope for v1.
Default rules:
undefinedvalues are omitted- scalar values produce a single key-value pair
- array values produce repeated keys
- values are serialized via string conversion in a predictable way
nullis serialized as the literal stringnull
Recommended default for arrays:
tags: ['a', 'b']becomestags=a&tags=b
This is widely understood and avoids introducing custom query conventions by default.
The package should not automatically flatten complex object graphs into query strings.
This introduces ambiguity and almost always leads to disagreement about “correct” behavior. Consumers who need specialized query encoding can pre-serialize it themselves.
The body option allows callers to pass a valid fetch-compatible body directly.
The package should not reinterpret or transform raw bodies except where strictly necessary to construct the request.
The json option exists to reduce boilerplate for the most common request-body use case.
Rules:
jsonandbodyare mutually exclusive- when
jsonis provided, the package serializes it withJSON.stringify - if
Content-Typeis not already set, it is set toapplication/json
The package should not perform schema validation or content introspection beyond what is necessary for consistent behavior.
As a general rule, bodies on GET and HEAD requests should be rejected or strongly constrained in v1.
Even though some systems tolerate them, they are unusual and frequently confusing. The package should prefer conservative behavior unless there is a compelling reason otherwise.
The package supports explicit response parsing modes:
jsontextblobarrayBufferraw
The default response type should be json unless explicitly changed. This reflects the most common modern use case and offers practical convenience.
This default must be clearly documented because it differs from raw fetch semantics.
When parsing JSON:
- Read response text
- Parse using the configured JSON parser
- Return parsed value
- Throw a parse error if parsing fails
The package must not silently fall back from JSON to text.
Silent fallback hides data problems and makes failures harder to reason about.
When responseType is json, an empty response body yields undefined.
For this purpose, a response body is considered empty if reading it yields an empty string.
This applies to 204, 205, and 304 responses and to other successful responses whose body is empty.
As a result, JSON responses are typed as T | undefined rather than T alone.
When responseType is raw, the original Response object is returned.
This is important for advanced consumers and serves as an escape hatch when the convenience layer should get out of the way.
Non-2xx responses must throw an HttpError before response parsing is returned to the consumer in the normal success path.
This is a deliberate divergence from native fetch behavior and a major part of the package’s value proposition.
HttpError may include bodyText for diagnostics, but capture should be bounded so large payloads do not cause avoidable memory pressure.
Diagnostic body capture may consume or cancel the HttpError.response body. Consumers that need to inspect a non-2xx response body should use bodyText or an afterResponse hook instead of assuming the retained Response body remains readable.
Errors should be:
- typed
- informative
- consistent
- suitable for control flow
- suitable for logging and debugging
The package must normalize low-level platform behavior into a small, explicit error model.
Represents invalid library usage or invalid request configuration.
Examples:
- mutually exclusive options
- invalid timeout
- malformed URL inputs where configuration is at fault
Represents transport failure where a response was not successfully received.
Examples:
- DNS resolution failure
- connection failure
- TLS negotiation failure
- generic fetch rejection not attributable to timeout or explicit abort
Represents a request aborted because the configured timeout elapsed.
This must be distinguishable from an externally triggered abort.
Represents an externally canceled request through an abort signal.
This distinction matters because timeouts and user-initiated cancellation often imply different remediation paths.
Represents a non-2xx HTTP response.
It should carry:
- status code
- status text
- response object
- request object when available
- optionally captured response body text where helpful and safe
Represents failure to parse a successful response according to the selected response type.
Most commonly this will be JSON parse failure.
The library must normalize platform-level errors consistently.
Normalization rules must distinguish:
- timeout-triggered aborts
- caller-triggered aborts
- fetch transport failures
- configuration failures inside library code
Where practical, original causes should be preserved.
The package should carry cause information when supported and appropriate, but the public contract should rely on the package’s own error classes rather than raw platform error shapes.
Timeouts are implemented using AbortController.
A timeout means:
- the package creates an internal abort controller
- the controller aborts once the configured timeout elapses
- timeout expiration produces a
TimeoutError
If the caller provides an external AbortSignal, that signal must be respected.
If the external signal aborts first, the request must fail with an abort error rather than a timeout error.
Where the runtime exposes an external abort reason, the package should preserve that reason as the AbortRequestError cause.
If both timeout and external abort are present, the package must compose them so that either can cancel the request.
Signal-composition behavior must be deterministic and well tested. The first abort wins: an external abort that fires before the timeout must not later be reclassified as a timeout, and a timeout that fires first must remain a timeout.
Timers created for timeouts must always be cleaned up, including in successful requests and early failures.
This is both a correctness and resource-management requirement.
Hooks provide a small and explicit mechanism for cross-cutting behavior such as:
- authentication header injection
- logging
- metrics
- centralized response handling
- structured error reporting
Hooks are not intended to become a general-purpose middleware framework.
Runs after request normalization but before final Request construction and network execution.
Permitted uses:
- set or modify headers
- inspect resolved URL and request configuration
- apply last-mile request policy
- replace the request URL with a final absolute URL
Runs immediately after a response is received and before success parsing is returned to the consumer.
afterResponse always receives the raw Response, including non-2xx responses that may later be classified as HttpError.
Permitted uses:
- inspect status codes
- record metrics
- implement cross-cutting response handling
Runs after a failure has been classified but before it is re-thrown to the caller.
For transport, timeout, external abort, HTTP, and parse failures, onError receives the normalized package error after classification has occurred.
For hook failures and request-construction failures, onError receives the error as thrown. This preserves the package's explicit-failure posture without wrapping consumer hook bugs or configuration failures as misleading network-layer errors.
Permitted uses:
- logging
- telemetry emitted by the consuming application
- centralized error observation
The package itself must not emit telemetry.
Hook order must be stable and documented.
Order:
- client hooks first
- request hooks second
Within each list, hooks run in the order they are defined.
If a hook throws, the request fails.
The package must not swallow hook errors silently.
This is consistent with the philosophy of explicit failure over hidden behavior.
beforeRequest may intentionally mutate the request context where documented, especially headers.
Hook mutation must remain constrained and explicit.
In particular:
- hooks may mutate headers
- hooks may replace the URL
- hooks may not mutate normalized execution options directly
afterResponseandonErrorare observational-only apart from throwing- hook metadata exposed through
context.optionsis read-only and must not act as a hidden mutation surface
If a beforeRequest hook replaces the URL, the replacement must be a fully resolved absolute URL. Relative replacement URLs are invalid and must fail with ConfigError.
When a hook replaces the URL, that replacement becomes the final URL for the request and overrides any previously resolved URL, including query parameters.
The library does not reapply baseURL, re-resolve relative paths, or merge query parameters after URL replacement.
Retries are useful but dangerous when overly permissive.
The package must treat retry behavior conservatively.
Retries should be:
- explicit
- bounded
- predictable
- safe by default
Retries should be disabled by default unless a future release has a compelling reason to enable a narrowly safe default.
When enabled, retries should default to safe cases such as:
GETHEAD- optionally
OPTIONS - transient network failure
- selected HTTP statuses such as
429,502,503,504 - replayable request bodies only
The package should not automatically retry unsafe methods such as POST, PUT, PATCH, or DELETE unless the caller explicitly configures that behavior.
Version 1 must also reject retry-enabled execution for streaming request bodies. Retries must not assume that all bodies can be replayed safely.
Retry delays should use bounded exponential backoff.
The strategy should be simple, deterministic, and documented.
The package should avoid introducing jitter in v1 unless there is a clear need and documentation story for it.
Version 1 uses per-attempt timeout semantics.
This means:
- each retry attempt gets its own timeout window
- the timeout does not represent a single total deadline across all attempts
- the timeout window starts after that attempt's
beforeRequesthooks complete - retry backoff delays occur between attempts and do not consume an attempt timeout window
This is simpler to implement and reason about. If a future version introduces total deadline support, it must do so explicitly.
Retry behavior should be visible to hook contexts where practical so consuming applications can log and understand repeated attempts.
Version 1 retry decisions are based only on:
- request method
- normalized failure type
- HTTP status code
Response bodies are not inspected for retry classification in v1.
The package’s runtime security posture is grounded in the following choices:
- zero runtime dependencies
- no lifecycle scripts
- no hidden network activity beyond the caller’s request
- no telemetry
- no dangerous convenience features by default
- no automatic retries for unsafe requests by default
- no custom protocol downgrades or transport hacks
The package must avoid logging or exposing sensitive headers automatically.
If helper utilities exist for diagnostics, they should support redaction of commonly sensitive header names such as:
AuthorizationCookieSet-Cookie- API-key style headers
The core package itself should avoid built-in logging.
Redirect handling should default to platform behavior unless explicitly exposed and documented.
The package must not introduce surprising redirect behavior on its own.
This package cannot prevent server-side request forgery by itself, but it must not make SSRF risks worse through hidden host rewriting, DNS tricks, or automatic request mutation.
Release hygiene is part of the product.
The repository and release process should include:
- protected branches
- CI-based publishing
- npm 2FA
- signed tags where practical
- strict package export maps
- files whitelisting in published package artifacts
- a
SECURITY.mddisclosure policy
Types should improve ergonomics without making false promises.
This package should use types to:
- make common use cases pleasant
- prevent obvious misuse
- model supported configuration accurately
- expose rich hook and error types
Generic response typing is consumer-directed, not runtime-validated.
For example:
const user = await client.get<User>('/users/123')This means “treat the parsed response as User,” not “the library has validated that the server returned a User.”
The documentation should be explicit about this distinction.
Where possible, incompatible option combinations should be discouraged or prevented through types.
Examples:
- discourage simultaneous
bodyandjson - constrain response-type values
- strongly type hook contexts and retry configuration
Runtime validation still remains necessary.
This section is intentionally repetitive because it protects the package from scope drift.
This package does not aim to:
- become Axios-compatible
- support every runtime environment
- provide a plugin ecosystem in core
- serialize arbitrary complex objects automatically
- perform content negotiation magic
- hide the semantics of fetch
- auto-detect what developers “meant”
- retry unsafe requests silently
- include broad transport abstractions
- become a framework for application networking policy
When in doubt, the design should favor restraint.
The implementation should be organized into small, focused modules.
Recommended internal responsibilities include:
- option validation
- URL construction
- header merging
- request construction
- timeout and signal composition
- hook execution
- fetch execution
- error normalization
- response parsing
- retry orchestration
Internal modules should remain boring and explicit.
Avoid:
- deep inheritance
- hidden mutable global state
- dynamic module loading
- internal plugin systems
- overly clever abstractions
A new maintainer should be able to understand the request flow quickly.
The source tree should reflect the request lifecycle and core concepts clearly. File organization should favor readability over theoretical purity.
The following invariants are part of the design contract.
- request-level options override client defaults
- header merging is deterministic
jsonandbodyare mutually exclusive- invalid configuration fails before network execution
- timeout timers are always cleaned up
- absolute URLs are not rewritten by
baseURL
- non-2xx responses throw
HttpError - response parsing follows explicit
responseType - JSON parse failures throw
ParseError rawreturns the originalResponse
- client hooks run before request hooks
- hooks run in definition order
- hook failures are not swallowed
onErrorruns after failure classification and before re-throw- hook and request-construction failures are surfaced as thrown
- retries are bounded
- retries are conservative
- retries are never silently applied to unsafe methods by default
- per-attempt timeout semantics are documented and stable
The package should make observability possible without performing observability itself.
That means:
- hooks expose the right context for application-level logging and metrics
- errors carry meaningful metadata
- retry behavior is inspectable where practical
It does not mean:
- built-in logging
- built-in telemetry
- outbound reporting
- analytics
Consumers own observability. The package provides clean surfaces for it.
Every behavior that differs meaningfully from native fetch must be documented clearly.
At minimum, the documentation must explain:
- default response parsing behavior
- non-2xx throwing behavior
- timeout semantics
- abort semantics
- retry semantics
- hook order
- header merge precedence
- query serialization rules
baseURLresolution rules- error classes and when each is thrown
Good documentation is part of the design, not an afterthought.
Changes to the following behaviors should be treated with high caution because they are semantically significant:
- merge precedence
- hook order
- retry defaults
- default response type
- non-2xx throwing behavior
- timeout semantics
- query serialization rules
- error classification
Any change to these areas is likely breaking or at least behaviorally important and should be evaluated accordingly.
The package should prefer stability over clever iteration once the core contract is established.
Before adding a feature, answer the following:
- Does it solve a real, common problem?
- Can it be explained simply?
- Does it preserve native-first design?
- Does it introduce hidden behavior?
- Does it increase attack surface?
- Does it require runtime dependencies?
- Does it complicate types significantly?
- Does it create new ambiguous interactions?
- Can it be tested thoroughly?
- Would a careful user expect this package to own this responsibility?
If the answers reveal significant complexity, the feature probably does not belong in core.
This package is intentionally narrow.
Its purpose is to provide the most valuable conveniences of a modern HTTP client while remaining:
- dependency-free
- modern
- explicit
- secure by design
- easy to audit
- easy to maintain
The design should always favor:
- clarity over cleverness
- explicitness over magic
- restraint over scope creep
- trustworthiness over feature count
That philosophy is the product.