lowdata
v0.2.6
Published
Resilience for the other half of the internet: automatic retries, an offline request queue, and bandwidth-aware media for web apps on poor connections.
Maintainers
Readme
lowdata
Resilience for the other half of the internet.
Automatic retries with backoff, an offline request queue that survives page reloads, offline-safe forms, and bandwidth-aware image compression — for web apps that have to keep working on 2G, on a flaky café Wi-Fi, or mid-load-shedding. Framework-agnostic, near-zero dependencies, fully typed.
import { createLowdataClient } from 'lowdata';
const client = createLowdataClient();
await client.fetch('/api/orders', { method: 'POST', body: JSON.stringify(order) });
// Online: sends immediately, retrying transient failures automatically.
// Offline (or the server keeps failing): queued to IndexedDB and sent the moment
// connectivity returns — even if the page was reloaded in the meantime.Why lowdata
Most fetch/UI code silently assumes a fast, stable connection. In much of the world — including huge parts of Africa — that assumption breaks constantly:
- Networks drop mid-session. A form submit or upload just... fails, and the input is gone.
- Mobile data is expensive. Every retry, every full-size photo upload, costs real money.
- "Slow" is the normal case, not the edge case. 2G/3G and congested Wi-Fi are common, not rare.
- Reloads happen. Low-end devices and unstable networks mean tabs get killed and reopened — in-progress form input and queued requests need to survive that.
lowdata doesn't try to be a full offline-first framework or a service-worker-based PWA toolkit. It solves the four concrete problems above, as simply as possible, and gets out of your way otherwise.
What makes lowdata different
Individually, every piece of lowdata sounds like something available elsewhere — a request queue, a retry policy, a canvas-based image resizer. Across a review of the closest comparable packages (general offline-request-queue libraries, and dedicated client-side image compression libraries), a few specific things aren't:
- Bandwidth-aware media compression.
compressImage()ties JPEG quality, max dimensions, and target file size directly to live connection quality (online/slow/offline) viapresetForQuality()— not a fixed setting you configure once. A photo compressed while offline comes out smaller than one compressed on a fast connection, because it's headed into the persistent queue, where every extra KB costs storage quota now and mobile data later. - A per-endpoint circuit breaker. When one host is persistently down,
CircuitBreakerbacks every queued item against it off together, instead of each one independently retrying (and failing) against a host already known to be down. resolveHeaders— auth refreshed before every retry, not just at enqueue time. A bearer token captured when a request was first queued can expire during a long offline stretch;resolveHeadersruns fresh immediately before every send attempt, including a retry firing hours later, so a stale token doesn't turn into a queue that 401s forever.Retry-Afterhandling built in.429/503responses carrying aRetry-Afterheader are honored automatically as part of the retry/backoff calculation, not left for you to parse and wire in.- One offline-queue core, five framework bindings. The same
LowdataClient/queue/sync-event model is exposed throughlowdata/react,lowdata/vue,lowdata/svelte,lowdata/angular, andlowdata/solid— each typed against its own framework's native primitives (hooks, Composition API refs, stores, RxJS Observables, Solid primitives), not one implementation with thin ports.
None of this needs a service worker or a runtime dependency — see "Bundle size" and "lowdata vs. alternatives" below.
Install
pnpm add lowdata
# or: npm install lowdata / yarn add lowdataReact, Vue, RxJS, and Solid are all optional peer dependencies — only needed if you use the
matching lowdata/react / lowdata/vue / lowdata/angular / lowdata/solid bindings. Svelte
needs no dependency at all (see Framework guides). Plain lowdata/lowdata/network/lowdata/forms work
from any framework, or none — see Runtime support for
non-browser hosts (React Native, Electron, Node).
Quick start
A shop owner in Lagos syncing inventory over 2G
import { createLowdataClient, isQueued } from 'lowdata';
const client = createLowdataClient({ baseUrl: 'https://api.example.com' });
const result = await client.fetch('/inventory/restock', {
method: 'POST',
body: JSON.stringify({ sku: 'RICE-25KG', delta: 40 }),
});
if (isQueued(result)) {
console.log('Saved locally — will sync automatically once the connection is back.');
} else {
console.log('Sent immediately:', result.status);
}No polling, no manual retry loop, no lost restock entries when the shop's connection drops mid-tap.
A rural clinic intake form that never loses data
import { createOfflineForm } from 'lowdata';
const form = createOfflineForm({ id: 'patient-intake', endpoint: '/api/patients' });
// Call on every field change — cheap, local, and survives a reload or a crashed tab.
await form.save({ name, age, symptoms });
// When the nurse taps "submit": sends now if possible, otherwise queues and syncs later.
const { status } = await form.submit({ name, age, symptoms });// React:
import { useOfflineForm } from 'lowdata/react';
function IntakeForm() {
const { status, submit } = useOfflineForm({ id: 'patient-intake', endpoint: '/api/patients' });
// status: 'idle' | 'saved' | 'pending' | 'syncing' | 'failed' | 'success'
return <StatusBadge status={status} />;
}A marketplace seller uploading a product photo without burning their data bundle
import { compressImage } from 'lowdata/media';
const { blob, sizeBytes } = await compressImage(photoFile, {
connectionAware: true, // aggressive on 'slow'/'offline', lighter-touch on 'online'
targetSizeKB: 200,
});
await client.fetch('/listings/photo', { method: 'POST', body: blob });Core concepts
Connection detection
import { getConnectionQuality, onConnectionChange } from 'lowdata';
getConnectionQuality(); // { quality: 'online' | 'slow' | 'offline', online, effectiveType?, ... }
const unsubscribe = onConnectionChange((info) => console.log(info.quality));Quality is computed from navigator.onLine/online-offline events (universal) plus
navigator.connection where available (Chromium browsers). On Safari/Firefox, 'slow' detection
needs an opt-in latency probe — never automatic, since every probe costs a little data:
createLowdataClient({ connection: { pingUrl: '/healthz', slowRttThresholdMs: 600 } });Offline queue & sync
Every mutating request (POST/PUT/PATCH/DELETE) that can't be delivered — offline, or the
server keeps failing — is written to IndexedDB and retried automatically, with priority ordering,
exponential backoff + jitter, and a cross-tab lock so two open tabs never double-send the same item:
await client.queue.add({ url: '/api/x', method: 'POST', priority: 'high', body: json });
await client.queue.list({ status: 'pending' });
await client.queue.cancel(id);
await client.queue.retry(id); // move a 'failed'/'expired'/'cancelled' item back to 'pending'
const unsubscribe = client.queue.subscribe((items) => renderQueueBadge(items.length)); // live in every open tab
client.onSync((event) => {
// 'sync-start' | 'item-start' | 'item-success' | 'item-failed' | 'item-expired'
// | 'items-blocked' (withheld by an unresolved dependsOn, or an open circuit breaker — the
// only way to see a stuck item that would otherwise never fire any other event at all)
// | 'circuit-open' | 'sync-complete'
});Background sync failures are deliberately never thrown (a queued item retrying in the background
shouldn't crash your app) — but they're not silent either. Pass onError to see them (a
console.warn is the default if you don't):
createLowdataClient({
onError: (error, { scope }) => reportToMonitoring(error, { scope }),
// scope: 'db-open' (IndexedDB unavailable, fell back to memory for the session)
// | 'db-operation' (one IndexedDB call failed — e.g. a transient quota error — persistence
// is still available, just that one call fell back)
// | 'sync' (the background sync loop hit an unexpected error)
// | 'quota' (proactive warning: origin storage is nearly exhausted)
// | 'decrypt' (a queued item's body couldn't be decrypted — it's marked 'failed' so it
// stops blocking the rest of the queue, but its data is unrecoverable)
});Ordering dependencies — don't sync a sale before the product it references has synced:
const product = await client.queue.add({ url: '/api/products', method: 'POST', body: productJson });
await client.queue.add({
url: '/api/sales',
method: 'POST',
body: saleJson,
dependsOn: [product.id], // withheld until product.id succeeds (or is cancelled)
});Expiring stale writes — never replay a request against data that's likely moved on:
await client.fetch('/api/checkout', { method: 'POST', body, maxAgeMs: 60_000 }); // 'expired', not sent, after 1 minute queuedPer-endpoint circuit breaker — many queued items against one persistently-down host back off together instead of each retrying (and failing) independently:
createLowdataClient({ circuitBreaker: { threshold: 5, cooldownMs: 30_000 } });Encryption at rest — a queued item's body is encrypted before it touches IndexedDB and
transparently decrypted on read; every other API (queue.list(), onSync, QueuedResult) always
sees plaintext:
createLowdataClient({
encryption: {
encrypt: (plaintext) => myCrypto.encrypt(plaintext),
decrypt: (ciphertext) => myCrypto.decrypt(ciphertext),
},
});Namespacing — isolate one client's queue from another's (e.g. per business/tenant), so switching context can't leak or cross-send another context's queued writes:
createLowdataClient({ namespace: currentBusinessId });Pluggable storage — swap IndexedDB for SQLite/AsyncStorage/anything else by implementing
StorageAdapter (five methods: put/get/getAll/delete/clear/count + isPersistent()),
useful for an Electron main process or React Native:
import { createMemoryStorageAdapter, type StorageAdapter } from 'lowdata';
createLowdataClient({ storage: myCustomAdapter }); // or createMemoryStorageAdapter() for tests/SSRSchema migration — upgrade queue items enqueued by an older build of your app that are still pending when the new one runs:
createLowdataClient({
schemaVersion: 2,
migrateQueueItem: (item) => ({ ...item, body: upgradeShape(item.body) }),
});Idempotency — every mutating request gets an auto-generated Idempotency-Key header (and
QueueItem.idempotencyKey) by default, whether it ends up sent live or queued — have your backend
dedupe on it. Opt out with autoIdempotencyKey: false, or supply your own via idempotencyKey.
Refreshing stale auth headers — a queue item's headers are otherwise frozen at enqueue time:
a bearer token captured while the app was last online can expire during a long offline stretch, and
every replay would then 401 forever with no recovery short of a manual queue.retry() after
re-auth. resolveHeaders is called fresh immediately before every send attempt of a queued item
— including a retry firing hours after the item was first enqueued — and its result is merged over
that item's stored headers (Idempotency-Key still layers on top of both, unchanged):
createLowdataClient({
resolveHeaders: async () => ({ Authorization: `Bearer ${await getFreshAccessToken()}` }),
});Only applies to background sends from the queue — a live client.fetch() call already carries the
caller's current headers, so there's nothing to refresh there.
Inspecting a queued write's actual outcome — item-success only tells you the request got a
2xx; it doesn't, by default, tell you what the server actually said. That distinction matters
when a 200 can mean two different things — e.g. a ticket-check-in endpoint returning
{ status: 'ALREADY_SCANNED' } with an HTTP 200, because a different device already checked this
same ticket in while both were offline. Opt in to see it:
createLowdataClient({ captureResponseBody: true }); // client-wide default...
await client.fetch('/api/checkin', { method: 'POST', body, captureResponseBody: true }); // ...or opt in per request, so only the endpoints that need it pay for it
client.onSync((event) => {
if (event.type === 'item-success' && event.response?.body?.status === 'ALREADY_SCANNED') {
// handle the business-level conflict — this was never a "failure" to retry
}
});Off by default (reading a body has a real memory cost most apps never need); bodies over
captureResponseBodyMaxBytes (default 100 KB) are skipped — only status is captured, never the
full body.
Retry & backoff
createLowdataClient({
retry: { maxRetries: 8, baseDelayMs: 500, maxDelayMs: 30_000, jitter: 'full' },
});Retries network errors, timeouts, and 429/502/503/504 (honoring Retry-After); 4xx
responses are returned to you immediately, unretried, so you can handle validation errors normally.
Offline forms
createOfflineForm composes save() (local, instant, reload-safe) with submit() (send now or
queue) and projects the queue's sync events into a simple status: idle → saved → pending → syncing
→ success, with failed/retry() on the unhappy path.
Media: compression, progressive images & resilient video
import { compressImage, createProgressiveImageLoader } from 'lowdata/media';
const loader = createProgressiveImageLoader({ src: fullImageUrl, placeholder: tinyBlurDataUrl });
loader.subscribe(({ src, isLoaded }) => setImgSrc(src));createResilientVideoLoader plays video from a caller-supplied, connection-aware list of fallback
sources (different CDN mirrors or bitrate renditions of the same video) — it picks a sensible
starting source for the current connection quality, falls back through the rest on error or stall,
and settles on a poster image once every source has failed, auto-retrying once on reconnect:
import { createResilientVideoLoader } from 'lowdata/media';
const video = createResilientVideoLoader({
sources: [
{ src: cdnA1080, quality: 'online', label: '1080p' },
{ src: cdnA480, quality: 'slow', label: '480p' },
],
poster: posterUrl,
posterPlaceholder: tinyBlurDataUrl,
});
video.subscribe((state) => render(state));
// Wire your own <video> element's onCanPlay/onLoadedData to video.reportPlayable(), and its
// onError/onStalled to video.reportError() — the loader never owns the element itself.This is multi-source fallback plus poster-only degradation, not offline video playback — there's no byte-level video caching in the offline queue; once every source and the poster are unreachable, there's nothing left to show.
API reference
| Subpath | Exports |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| lowdata | createLowdataClient, LowdataClient, isQueued, LowdataRequestError, createOfflineForm, getConnectionQuality, onConnectionChange, createIndexedDbStorageAdapter, createMemoryStorageAdapter, createLocalStorageAdapter, CircuitBreaker, core types |
| lowdata/network | Everything in the root, plus RequestQueue, SyncManager, ConnectionMonitor, defaultRetryOn, defaultBreakerKey, createQueueBroadcast |
| lowdata/forms | createOfflineForm, form types |
| lowdata/media | compressImage, createProgressiveImageLoader, presetForQuality, createResilientVideoLoader, pickInitialSourceIndex |
| lowdata/react | useConnectionStatus, useLowdataClient, useOfflineForm, useProgressiveImage, useResilientVideo |
| lowdata/vue | useConnectionStatus, useLowdataClient, useOfflineForm, useProgressiveImage, useResilientVideo (Composition API) |
| lowdata/svelte | connectionStatus, createOfflineFormStore, createProgressiveImageStore, createResilientVideoStore, createLowdataClient (stores; zero dependency on svelte) |
| lowdata/angular | connectionStatus$, onSync$, offlineFormStatus$, progressiveImageState$, resilientVideoState$, createLowdataClient, createOfflineForm, createResilientVideoLoader (RxJS Observables) |
| lowdata/solid | createConnectionStatus, createLowdataClient, createOfflineForm, createProgressiveImage, createResilientVideo (Solid primitives) |
Full type signatures are in each subpath's shipped .d.ts — every export is documented with TSDoc.
Recipes
Prioritize a request:
await client.fetch('/urgent', { method: 'POST', body, priority: 'high' });Cancel a stale request:
const result = await client.queue.add({ url, method: 'POST', priority: 'normal', body });
await client.queue.cancel(result.id);Custom retry policy per request:
await client.fetch(url, { method: 'POST', body, retry: { maxRetries: 2 } });Server-side idempotency: every mutating request carries an auto-generated idempotencyKey
(sent as an Idempotency-Key header) by default — have your backend dedupe on it, since a retried
or cross-tab-raced request is still possible in rare edge cases.
Framework guides
Every UI-framework subpath is a thin binding over the same framework-agnostic core (lowdata,
lowdata/forms, lowdata/media) — same LowdataClient, same queue, same events. Pick the one
matching your stack; mixing is fine too (e.g. an Angular app can still call createOfflineForm
from lowdata directly).
Vanilla JS / any framework not listed below: use
lowdata/lowdata/forms/lowdata/mediadirectly — no framework glue needed, nothing here assumes a specific framework exists.React (
lowdata/react):useConnectionStatus,useLowdataClient,useOfflineForm,useProgressiveImage,useResilientVideo— plus a re-export of the rawcreateLowdataClient/LowdataClientfor code that needs a client outside a component's lifecycle (a Redux/Zustand store, a module-level singleton).reactis an optional peer dependency (>=17).Vue (
lowdata/vue): the same five composables (Composition-API-native —Refs, cleaned up viaonScopeDispose— so they work from a bareeffectScope(), not just inside a component'ssetup()) plus the same rawcreateLowdataClient/LowdataClientre-export as React, for a Pinia store or other non-component usage.vueis an optional peer dependency (>=3).import { useConnectionStatus, useOfflineForm } from 'lowdata/vue'; const status = useConnectionStatus(); // Ref<ConnectionInfo> const form = useOfflineForm({ id: 'clinic-intake', endpoint: '/api/patients' }); // form.status is a Ref<FormStatus>; form.save/submit/retry are plain async functionsSvelte (
lowdata/svelte): stores, not hooks —connectionStatus(),createOfflineFormStore,createProgressiveImageStore,createResilientVideoStoreall return an object satisfying Svelte's store contract (.subscribe(run): unsubscribe). This subpath needs no dependency onsvelteitself — the contract is structural, so$-auto-subscription works regardless.<script> import { connectionStatus, createOfflineFormStore } from 'lowdata/svelte'; const status = connectionStatus(); const form = createOfflineFormStore({ id: 'clinic-intake', endpoint: '/api/patients' }); </script> <p>{$status.quality} — {$form}</p>Angular (
lowdata/angular): RxJS Observables —connectionStatus$(),onSync$(client),offlineFormStatus$(form),progressiveImageState$(),resilientVideoState$(loader)— each multicast viashareReplayso several template| asyncbindings share one underlying listener. UnlikeprogressiveImageState$(),resilientVideoState$takes an already-createdResilientVideoLoader(build one withcreateResilientVideoLoader, re-exported from this subpath alongsidecreateOfflineForm) — you own it, and must callloader.destroy()yourself (e.g. inngOnDestroy) since unsubscribing from the Observable alone doesn't. No@angular/coreimport, no decorators, so there's no Angular-major-version coupling; wrap in your own@Injectable()service as needed.rxjsis an optional peer dependency (>=7, already present in virtually every Angular app).import { connectionStatus$ } from 'lowdata/angular'; // in a service: readonly status$ = connectionStatus$(); // in a template: {{ (status$ | async)?.quality }}Solid (
lowdata/solid): primitives following Solid's owncreateXconvention —createConnectionStatus,createLowdataClient,createOfflineForm,createProgressiveImage,createResilientVideo— cleaned up viaonCleanup, so they work inside any reactive root, not just a component.solid-jsis an optional peer dependency (>=1).import { createConnectionStatus } from 'lowdata/solid'; const status = createConnectionStatus(); // Accessor<ConnectionInfo> — call status() to read
Multi-tenant apps
Give each tenant its own LowdataClient, namespaced — this isolates everything that client
owns: its queue (a separate physical IndexedDB database), its circuit breaker (a fresh instance per
client, never shared across tenants even against the same API origin), and — critically — any
createOfflineForm built from it, whose drafts are stored through that same client's own adapter
rather than one hardcoded shared database:
const client = createLowdataClient({ namespace: currentBusinessId });
const form = createOfflineForm({ id: 'clinic-intake', endpoint: '/api/patients', client });
// Two different businessIds here never share a queue, a draft, or a breaker's failure count.One caveat: createOfflineForm/hooks called without an explicit client fall back to one
lazily-created default (unnamespaced) client, shared by every such call in the process — fine for
single-tenant apps, but always pass your tenant's client explicitly in a multi-tenant one.
Runtime support (React Native, Electron, Node)
The core has no built-in adapter for these — no runtime dependency is added on your behalf — but every extension point needed to run there is already exposed:
- Storage: React Native/Electron's main process/Node have no
indexedDB. For React Native,createAsyncStorageAdapterships a ready-madeStorageAdapterover@react-native-async-storage/async-storage(or anything with the same four methods) — no peer dependency added, since it's described structurally rather than imported:
Separately — this one is for a browser context, not these non-browser hosts: where IndexedDB itself is unavailable or crippled (Safari private browsing, some locked-down in-app webviews) but synchronousimport AsyncStorage from '@react-native-async-storage/async-storage'; import { createLowdataClient, createAsyncStorageAdapter } from 'lowdata'; createLowdataClient({ storage: createAsyncStorageAdapter(AsyncStorage, `event-${eventId}`) }); // the second argument namespaces the same way `namespace` does on the web — isolate a shared // device's data per event/organizer/tenant instead of one global queue.localStoragestill works,createLocalStorageAdapter()is a ready-made fallback. It is not a replacement for IndexedDB at scale —localStorage's real-world quota is only ~5-10 MB total per origin, and every read/write is synchronous:
For Electron's main process or a Node backend, implementimport { createLowdataClient, createLocalStorageAdapter } from 'lowdata'; createLowdataClient({ storage: createLocalStorageAdapter() });StorageAdapterdirectly (five methods:put/get/getAll/delete/clear/count) over SQLite or anything else — or fall back to the automatic in-memory adapter for a session with no persistence needs. - Connectivity: without a DOM
window, lowdata can't hear a browseronline/offlineevent. Feed it your own signal — React Native'sNetInfo, Electron's own reachability check — and the existing reconnect-triggers-a-drain behavior works unchanged:NetInfo.addEventListener((state) => client.connection.report({ online: !!state.isConnected, quality: state.isConnected ? 'online' : 'offline', }), ); - Manual sync: the periodic safety poll relies on
document.visibilityState, which doesn't exist off the DOM either. Callclient.sync()from whatever does signal "maybe back online" there —AppStateforegrounding, a pull-to-refresh, a cron tick in a Node service:AppState.addEventListener('change', (state) => { if (state === 'active') void client.sync(); }); lowdata/mediais browser-only —compressImageneeds Canvas/OffscreenCanvas with no fallback, andcreateResilientVideoLoader/createProgressiveImageLoader, while SSR-safe to import (both guard ontypeof Image !== 'undefined'), still assume a real<video>/<img>DOM element to be useful. Don't import this subpath from React Native or Node; it's separate specifically so you never pay for it there.
Browser & runtime support
- Requires
fetch,AbortController, andPromise— all standard in any target browser (and present in modern Node/React Native too). - IndexedDB persists the offline queue and form drafts. Where it's unavailable (some SSR
contexts, locked-down private-browsing modes, React Native/Electron/Node — see above), lowdata
falls back to an in-memory queue with a console warning instead of throwing — nothing breaks,
offline persistence is just unavailable until you supply a
storageadapter.createLocalStorageAdapter()is a ready-made option for exactly that fallback — see "Runtime support" above. - SSR-safe to import:
createLowdataClient()and friends never assumewindow/navigatorexist; on the server, connection quality reports'online'and nothing touches the DOM.
Known limitations & roadmap
- Sync only runs while a tab is open. There's no Service Worker in v1 (by design — see "vs. alternatives" below) — closing the tab while offline defers sync to the next time the app is opened, not true background sync.
- No CRDT/merge conflict resolution. lowdata detects and reports conflicts (via your backend, e.g. comparing a device's timestamp against the server's) but doesn't attempt to auto-merge divergent writes — pair its idempotency keys with your own server-side conflict policy.
- Live cross-tab queue state, storage-quota warnings, and a per-endpoint circuit breaker are all
implemented — see
queue.subscribe(), the'quota'error scope, andcircuitBreakerabove. - Resilient video is multi-source fallback + poster-only degradation, not offline video
playback. There's no byte-level video caching/persistence in the offline queue — once every
source in
createResilientVideoLoader'ssources[]and the poster are unreachable, there's nothing left to show.
See ROADMAP.md for what's still deliberately deferred and why.
Bundle size
Sizes below are the unminified ESM build's gzip size — real-world minified size (via your app's bundler) will be smaller. Each subpath is independently tree-shakeable; you only pay for what you import.
| Subpath | gzip (unminified) |
| -------------------------------------------------------- | ------------------ |
| lowdata (core + network + forms) | ~18.5 KB |
| lowdata/network alone | ~17.4 KB |
| lowdata/media alone | ~5.2 KB |
| lowdata/react / vue / svelte / angular / solid | ~18.3-18.5 KB each |
Includes createLocalStorageAdapter and the Worker/OffscreenCanvas image-compression path — both
additive, neither adds a dependency. The worker itself is a small inline string, not a separate
asset; it never loads unless compressImage() is actually called in an environment that supports
it.
Framework subpaths are each a standalone bundle (not a thin diff on top of lowdata) — importing
one doesn't require also fetching the root package separately.
lowdata/media's image compression (the heaviest single feature — canvas resize + iterative
quality search) is never pulled in by the root import; you opt in explicitly via lowdata/media.
lowdata vs. alternatives
- vs. Service Worker background sync: lowdata needs no service worker registration, no separate sync event handler, no HTTPS-only constraint for local dev — at the cost of only syncing while a tab is open. If you need true background sync after the tab closes, pair lowdata's queue format with your own service worker, or wait for a future release.
- vs.
axios-retry/generic retry libraries: those retry a single in-flight request; lowdata additionally persists failed/offline requests to survive a reload and syncs them automatically. - vs. other offline-queue/request-persistence libraries: several small packages persist and
retry queued requests the way lowdata does; none reviewed ship a per-endpoint circuit breaker, a
resolveHeadershook that re-runs before every retry (not just at enqueue time), or built-inRetry-Afterhandling — those still need to be hand-rolled on top. - vs. static-config image compression libraries: general-purpose client-side compressors
(Canvas- or WASM-based) apply a fixed quality/dimension setting regardless of the user's actual
connection. lowdata's
connectionAwarepresets tie compression aggressiveness to live connection quality instead, and — like the more established libraries in this space — run off the main thread viaWorker/OffscreenCanvaswhere available, falling back to the main thread otherwise. - vs. building it yourself: this is the boring, well-tested version of the offline queue + retry + form-status code most apps end up hand-rolling anyway.
Contributing
See CONTRIBUTING.md, VERSIONING.md for the (fully automated) release process, and SECURITY.md to report a vulnerability.
License
MIT — see LICENSE.
