Skip to content

Jayetheus/axiom

Repository files navigation

Axiom logo

Axiom

Offline-first fetch for React, Next.js, React Native, and TypeScript apps.

Queue writes when the network fails. Replay them safely with backoff, persistence, and developer-friendly hooks.

npm version npm downloads License: MIT

Why Axiom

Most apps handle offline writes badly:

  • the request fails
  • the user retries
  • the backend gets duplicates
  • the frontend loses track of what actually happened

Axiom wraps fetch with an opinionated offline workflow:

  • mutation requests can be queued when the network drops
  • requests persist locally with IndexedDB, localStorage, or a custom adapter
  • retries run with exponential backoff and jitter
  • dead letters are surfaced for intervention instead of silently looping forever
  • React apps get queue state and sync controls out of the box

What It Guarantees

Axiom provides at-least-once delivery, not exactly-once execution.

That is the right tradeoff for an offline client, but it means your backend should honor the Idempotency-Key header that Axiom sends for mutations by default. If your API already supports idempotent writes, Axiom fits naturally.

Installation

npm install @jayethian/axiom
# or
yarn add @jayethian/axiom
# or
pnpm add @jayethian/axiom

Quick Start

import { AxiomProvider, axiom } from "@jayethian/axiom";

export default function App({ children }) {
  return (
    <AxiomProvider
      config={{
        baseURL: "https://api.myapp.com",
        timeout: 8000,
        maxRetries: 5,
      }}
      fallbackAdapter="indexeddb"
    >
      {children}
    </AxiomProvider>
  );
}

async function saveOrder(payload: unknown) {
  const result = await axiom.post("/orders", payload, {
    idempotencyKey: "order-123",
  });

  if (result.isQueued) {
    console.log("Offline. Order queued for background replay.");
  }
}

Core Value

// Standard fetch:
await fetch("/api/orders", {
  method: "POST",
  body: JSON.stringify(order),
});

// Axiom:
await axiom.post("/orders", order, {
  idempotencyKey: order.id,
});

When the network is stable, it behaves like a normal request flow.
When the network fails, Axiom stores the write and retries it later instead of dropping the action on the floor.

Features

  • Persistent offline queue with built-in IndexedDB, localStorage, and memory adapters.
  • Automatic idempotency-key injection for POST, PUT, PATCH, and DELETE.
  • Sequential replay with batching, exponential backoff, and jitter.
  • Dead-letter support for permanently failing requests.
  • Queue deduplication for repeated explicit idempotency keys.
  • onBeforeSync hook for refreshing auth headers before replay.
  • React provider and hooks for queue inspection and manual sync.
  • Custom storage adapter support for MMKV, AsyncStorage, or internal platform stores.

React and Next.js

For web apps, AxiomProvider automatically binds to the browser online and offline events.

import { AxiomProvider } from "@jayethian/axiom";

export function RootLayout({ children }) {
  return (
    <AxiomProvider
      config={{
        baseURL: "https://api.myapp.com",
        retryBaseDelayMs: 1500,
        maxRetries: 4,
      }}
      fallbackAdapter="indexeddb"
    >
      {children}
    </AxiomProvider>
  );
}

Note: in SSR environments, persistence only exists on the client. Server runtimes fall back to memory storage.

React Native

React Native does not provide window, IndexedDB, or localStorage, so you should pass both:

  • a custom networkListener
  • a persistent storageAdapter
import NetInfo from "@react-native-community/netinfo";
import { MMKV } from "react-native-mmkv";
import {
  AxiomProvider,
  AxiomStorageAdapter,
  QueuedRequest,
} from "@jayethian/axiom";

const mmkv = new MMKV();

class MMKVAdapter implements AxiomStorageAdapter {
  private queueKey = "axiom_queue";
  private deadLetterKey = "axiom_dead_letters";

  private read(key: string): QueuedRequest[] {
    const value = mmkv.getString(key);
    return value ? JSON.parse(value) : [];
  }

  private write(key: string, value: QueuedRequest[]) {
    mmkv.set(key, JSON.stringify(value));
  }

  async save(request: QueuedRequest) {
    const queue = this.read(this.queueKey).filter((item) => item.id !== request.id);
    queue.push(request);
    this.write(this.queueKey, queue);
  }

  async getAll() {
    return this.read(this.queueKey);
  }

  async remove(id: string) {
    this.write(
      this.queueKey,
      this.read(this.queueKey).filter((item) => item.id !== id),
    );
  }

  async clearAll() {
    mmkv.delete(this.queueKey);
  }

  async saveDeadLetter(request: QueuedRequest) {
    const queue = this.read(this.deadLetterKey).filter((item) => item.id !== request.id);
    queue.push(request);
    this.write(this.deadLetterKey, queue);
  }

  async getDeadLetters() {
    return this.read(this.deadLetterKey);
  }

  async clearDeadLetters() {
    mmkv.delete(this.deadLetterKey);
  }
}

export default function App({ children }) {
  return (
    <AxiomProvider
      config={{ baseURL: "https://api.myapp.com" }}
      storageAdapter={new MMKVAdapter()}
      networkListener={(callback) =>
        NetInfo.addEventListener((state) => callback(Boolean(state.isConnected)))
      }
    >
      {children}
    </AxiomProvider>
  );
}

Vanilla TypeScript Usage

import { axiom } from "@jayethian/axiom";

axiom.create({
  baseURL: "https://api.myapp.com",
  maxRetries: 4,
});

axiom.on("syncSuccess", ({ request, response }) => {
  console.log("Synced:", request.url, response);
});

axiom.on("syncFailure", ({ request, status, willRetry, nextRetryAt }) => {
  console.log("Sync failed:", request.url, status, willRetry, nextRetryAt);
});

await axiom.post("/orders", { sku: "book-1" }, { idempotencyKey: "book-1" });

React Hooks

import { axiom, useAxiomQueue } from "@jayethian/axiom";

export function CheckoutButton() {
  const { isOnline, inspectQueue, deadLetters, forceSync } = useAxiomQueue();

  const submit = async () => {
    const result = await axiom.post("/checkout", { sku: "book-1" }, {
      idempotencyKey: "checkout-book-1",
    });

    if (result.isQueued) {
      const pending = await inspectQueue();
      console.log("Queued requests:", pending.length);
    }
  };

  return (
    <div>
      <button onClick={submit}>Save order</button>
      <button onClick={forceSync} disabled={!isOnline}>
        Force sync
      </button>
      {deadLetters.length > 0 && <p>Some requests need manual attention.</p>}
    </div>
  );
}

Configuration

AxiomConfig

Property Type Default Description
baseURL string undefined Prepends a base URL to request paths.
defaultHeaders Record<string, string> {} Global headers applied to every request.
timeout number 8000 Foreground request timeout in milliseconds.
maxRetries number 3 Attempts before a queued request is dead-lettered.
queueReads boolean false Allows replaying failed GET requests.
autoIdempotency boolean true Injects an Idempotency-Key when one is not provided.
retryBaseDelayMs number 1000 Base delay for exponential retry backoff.
retryMaxDelayMs number 30000 Upper bound for retry backoff.
retryJitter number 0.2 Randomization ratio used to spread retries.
syncBatchSize number 10 Maximum eligible requests processed per flush.
fallbackAdapter "indexeddb" | "localstorage" | "memory" "memory" Built-in storage adapter preference.

AxiomRequestOptions

Property Type Description
priority "urgent" | "background" Reorders queued items during replay.
timeout number Overrides the foreground timeout for a single request.
headers Record<string, string> Appends or overwrites request headers.
idempotencyKey string Explicit key for backend dedupe.
metadata any Custom metadata persisted with the queue entry.

Production Notes

  • Mutations are queued by default. GET requests are not queued unless queueReads: true is enabled.
  • Axiom retries sequentially and stops the active flush after the first transient failure to avoid storming weak links.
  • Automatic idempotency keys make delayed writes safer, but the strongest dedupe comes from passing your own stable business key.
  • Dead letters are only persisted when the active storage adapter implements saveDeadLetter, getDeadLetters, and clearDeadLetters.
  • onBeforeSync should only mutate headers or metadata. Do not mutate the queued request id.

Roadmap-Friendly Use Cases

  • Checkout and payment intent creation
  • Field sales apps with unstable mobile coverage
  • Offline-first note taking and data collection
  • Queue-backed mobile mutations in React Native
  • Admin tools that need reliable background replay without dragging in a full sync framework

Contributing

Contributions, issues, and feature requests are welcome at Jayetheus/axiom.

License

MIT. Built by Jayetheus.

About

Offline-first fetch with persistent queueing, backoff, idempotency, and React helpers.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors