Swoff Swoff

API Integration

Coordinate client-side fetches with the service worker — cache strategy, tags, and invalidation.

Prerequisites: You have the SW registered from your app (Client Registration) and PWA manifest deployed (PWA Installability). This pattern shows how to mark client requests so the SW can cache responses for offline reads and invalidate them after mutations.


How It Works

The SW checks two request headers set by the client:

HeaderValueSW behavior
X-SW-Cache-StrategyreadCache the response, serve from cache on repeat requests
X-SW-Cache-StrategymutationLet the request pass through to the server, no caching
X-SW-Cache-Tagstodos,user:1Index the cached response under these tags for later invalidation
X-SW-StaletrueServe cached immediately, refresh in background (stale-while-revalidate)

If the strategy header is absent, the SW falls back to method-based heuristic: GET/HEAD are reads, everything else is a mutation.

URL-pattern strategies are configured in swoff.config.json:

{
  "serviceWorker": {
    "strategy": {
      "default": "cache-first",
      "patterns": {
        "/api/*": "network-first",
        "/static/*": "cache-first"
      }
    }
  }
}

See Configuration Reference for available strategies.


Fetch Wrapper

The CLI generates swoff/fetch-wrapper.js on every generate run. It sets headers automatically so you don't need to manage them manually.

import { fetchWithCache } from "./swoff/fetch-wrapper.js";

// GET — cached as a read, tagged for later invalidation
const todos = await fetchWithCache("/api/todos", {
  tags: ["todos"],
}).then((r) => r.json());

// POST — treated as mutation, passes through to server
await fetchWithCache("/api/todos", {
  method: "POST",
  body: JSON.stringify({ title: "New task" }),
});

// Stale-while-revalidate — serve cached, refresh in background
const data = await fetchWithCache("/api/todos", {
  tags: ["todos"],
  staleWhileRevalidate: true,
}).then((r) => r.json());

Header logic

fetchWithCache auto-detects read vs mutation by HTTP method:

headers.set(
  "X-SW-Cache-Strategy",
  method === "GET" || method === "HEAD" ? "read" : "mutation",
);

Pass headers explicitly to override:

// POST search — override to read with tags
const results = await fetchWithCache("/api/search", {
  method: "POST",
  headers: { "X-SW-Cache-Strategy": "read" },
  tags: ["search"],
  body: JSON.stringify({ query: "hello" }),
}).then((r) => r.json());

Caching Strategies

Configure per-URL strategies in swoff.config.json. The SW uses the defaultStrategy for unmatched routes.

StrategyWhen to useTrade-off
cache-firstStatic assets, navigation, rarely-changed API dataUser may see stale data until SW refreshes
stale-while-revalidateFrequently accessed API dataExtra background request on every read
network-firstReal-time data, formsSlower when offline, no stale data risk
cache-onlyPurely local data (never goes to network)Data must be pre-cached
network-onlyMutations, auth, live dataNo offline access

Strategy detection

The SW checks X-SW-Cache-Strategy header first, then falls back to URL-pattern matching from config.serviceWorker.strategy.patterns.


Stale-While-Revalidate

Serve cached data instantly, refresh in the background. The user always sees data immediately if it's been fetched before.

  1. Returns the cached response immediately (if available)
  2. Fetches fresh data from the network in the background
  3. Updates the cache with fresh data for next time
const todos = await fetchWithCache("/api/todos", {
  tags: ["todos"],
  staleWhileRevalidate: true,
}).then((r) => r.json());

If no cached response exists yet, the SW fetches from network and caches normally.


Query Deduplication

When two components request the same URL concurrently, the second caller reuses the in-flight request instead of firing a duplicate.

// Both mount and request the same data
const wrapper = await fetchWithCache("/api/todos", { tags: ["todos"] });
const details = await fetchWithCache("/api/todos", { tags: ["todos"] });
// Only one network request is made — the second gets a clone

The dedup map tracks in-flight GET requests by URL. When a request completes, its entry is removed.



Error Handling

Network timeout

const TIMEOUT_MS = 10000;

async function fetchWithTimeout(input, options = {}) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);

  try {
    const response = await fetch(input, { ...options, signal: controller.signal });
    return response;
  } catch (error) {
    if (error.name === "AbortError") {
      throw new Error("Request timed out");
    }
    throw error;
  } finally {
    clearTimeout(timeout);
  }
}

Offline fallback

When the user goes offline, reads should serve from cache and mutations should be queued. The CLI generates fetchWithCacheOrQueue in swoff/fetch-wrapper.js which handles this automatically — import it directly:

import { fetchWithCacheOrQueue } from "./swoff/fetch-wrapper.js";

async function getData() {
  try {
    return await fetchWithCacheOrQueue("/api/data");
  } catch (err) {
    if (err.message === "Offline: mutation queued") {
      // Show queued confirmation
    } else {
      // Show cached fallback or error UI
    }
  }
}

Server error retry

Server returns 5xx — retry with exponential backoff:

async function fetchWithRetry(url, options = {}, retries = 3) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url, options);
      if (response.status >= 500 && attempt < retries) {
        await new Promise((r) => setTimeout(r, attempt * 1000));
        continue;
      }
      return response;
    } catch (error) {
      if (attempt === retries) throw error;
      await new Promise((r) => setTimeout(r, attempt * 1000));
    }
  }
}

Cache corruption

A cached response may be corrupted or return an error status. Validate before serving:

async function validateCachedResponse(response) {
  if (!response || !response.ok) return null;
  try {
    const clone = response.clone();
    await clone.text();
    return response;
  } catch {
    const cache = await caches.open("swoff-runtime");
    await cache.delete(response.url);
    return null;
  }
}

REST API Examples

import { fetchWithCache } from "./swoff/fetch-wrapper.js";

// GET collection
const todos = await fetchWithCache("/api/todos", {
  tags: ["todos"],
}).then((r) => r.json());

// GET single item
const todo = await fetchWithCache("/api/todos/1", {
  tags: ["todos", "todo:1"],
}).then((r) => r.json());

// POST create
await fetchWithCache("/api/todos", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title: "New task" }),
});

GraphQL

Queries and mutations both POST to one endpoint. Inspect the operation string to set the right strategy and tags:

import { fetchWithCache } from "./swoff/fetch-wrapper.js";

async function graphql(query, variables = {}) {
  const isMutation = query.trimStart().startsWith("mutation");
  const operationName = extractOperationName(query);

  const data = await fetchWithCache("/graphql", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-SW-Cache-Strategy": isMutation ? "mutation" : "read",
    },
    tags: isMutation ? undefined : [operationName],
    body: JSON.stringify({ query, variables }),
  }).then((r) => r.json());

  if (data.errors) throw new Error(data.errors[0].message);
  return data.data;
}

function extractOperationName(query) {
  const match = query.trim().match(/^(query|mutation)\s+(\w+)/);
  return match ? match[2] : "unknown";
}

// Query
const user = await graphql(`query todos { todos { id title } }`);

// Mutation
const updated = await graphql(
  `mutation { updateUser(id: 1, name: "Alice") { name } }`,
);

Next Steps

On this page