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:
| Header | Value | SW behavior |
|---|---|---|
X-SW-Cache-Strategy | read | Cache the response, serve from cache on repeat requests |
X-SW-Cache-Strategy | mutation | Let the request pass through to the server, no caching |
X-SW-Cache-Tags | todos,user:1 | Index the cached response under these tags for later invalidation |
X-SW-Stale | true | Serve 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.
| Strategy | When to use | Trade-off |
|---|---|---|
cache-first | Static assets, navigation, rarely-changed API data | User may see stale data until SW refreshes |
stale-while-revalidate | Frequently accessed API data | Extra background request on every read |
network-first | Real-time data, forms | Slower when offline, no stale data risk |
cache-only | Purely local data (never goes to network) | Data must be pre-cached |
network-only | Mutations, auth, live data | No 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.
- Returns the cached response immediately (if available)
- Fetches fresh data from the network in the background
- 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 cloneThe 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
- Invalidation Tags — generate tags from URLs automatically
- Mutation Queuing — offline write queue
Swoff