Skip to content

Caching Strategy

The EngageHQ Public API is designed for high-volume traffic with aggressive CDN caching and automatic invalidation. Content is served from edge locations worldwide and refreshed within seconds of being published.

Cache Architecture

┌─────────────┐     ┌─────────────────┐     ┌─────────────────┐     ┌─────────────┐     ┌──────────┐
│   Browser   │────▶│   CDN (ours)    │────▶│  Vapor CF       │────▶│  API Server │────▶│ Database │
│    Cache    │◀────│  cdn.engagehq.* │◀────│  api.engagehq.* │◀────│    Cache    │◀────│          │
└─────────────┘     └─────────────────┘     └─────────────────┘     └─────────────┘     └──────────┘
    max-age          24h + invalidation       pass-through          Redis/Memory          MySQL

How It Works

  1. CDN layer (cdn.engagehq.retailsuccessplatform.com) — caches all public content for 24 hours at edge locations. When content is published, updated, or deleted, the CDN cache is automatically invalidated and the next request fetches fresh data from the origin.

  2. Browser layer — short TTLs via max-age control how long the user's browser caches responses before checking the CDN again.

  3. Stale-while-revalidate — allows browsers to serve cached content immediately while refreshing in the background, providing instant page loads.

Always use the CDN endpoint

For public content delivery, use cdn.engagehq.retailsuccessplatform.com instead of the direct API endpoint. The CDN provides lower latency, higher availability, and automatic cache management.

Cache-Control Headers

Every response includes Cache-Control headers that control browser caching:

http
Cache-Control: public, max-age=300, s-maxage=0, stale-while-revalidate=300

Header Components

DirectivePurposeWho Uses It
max-ageBrowser cache durationEnd user's browser
s-maxage=0Disables intermediate proxy cachingInternal infrastructure
stale-while-revalidateGrace period for async refreshBrowser

CDN caching is separate

The CDN does not use Cache-Control headers for its caching decisions. It uses a policy-based 24-hour cache with on-demand invalidation. The s-maxage=0 directive is an internal infrastructure detail — you don't need to worry about it.

Stale-While-Revalidate (SWR)

During the SWR window, the browser serves cached content immediately while refreshing in the background:

Timeline (content detail endpoint, max-age=300, swr=300):
├── 0-300s: Fresh from browser cache (instant)
├── 300-600s: Stale but served instantly, refresh happens in background
└── 600s+: Must wait for fresh response from CDN

This means users almost never see a loading spinner for content — the browser either has fresh data or serves stale data while fetching an update.

Cache TTLs by Endpoint

EndpointBrowserSWRCDNRefreshed
GET /content60s60s24hOn publish
GET /content/{slug}300s300s24hOn publish
GET /circulars60s300s24hOn publish
GET /circulars/{id}300s300s24hOn publish
GET /offers0s30s24hOn publish
GET /offers/{id}0s30s24hOn publish

Why Different Browser TTLs?

  • Content detail (300s) — individual pages change infrequently; a 5-minute browser cache reduces repeat requests while the CDN ensures freshness after edits
  • Content listing (60s) — lists may include newly published items; shorter browser TTL ensures new content appears within a minute
  • Offers (0s) — time-sensitive validity rules mean browsers should always check the CDN for the latest data

CDN Cache Behavior

All public endpoints share the same CDN behavior:

  • 24-hour cache at edge locations worldwide
  • Automatic invalidation when content is published, updated, or deleted
  • Per-location isolation — different locations/organizations get separate cache entries via X-Organization-Context
  • Query string aware — different filter combinations are cached independently

ETag Support

All responses include an ETag header for conditional requests:

http
ETag: "a1b2c3d4e5f6"

Using ETags

Make conditional requests with If-None-Match:

javascript
const response = await fetch('https://cdn.engagehq.retailsuccessplatform.com/api/v1/public/content/summer-sale', {
  headers: {
    'Authorization': `Bearer ${token}`,
    'X-Organization-Context': organizationId,
    'If-None-Match': '"a1b2c3d4e5f6"'  // Previous ETag
  }
});

if (response.status === 304) {
  // Content hasn't changed, use cached version
  console.log('Using cached content');
} else {
  // Content changed, use new response
  const newETag = response.headers.get('ETag');
  const data = await response.json();
}

Vary Header

Responses include a Vary header indicating which request headers affect caching:

http
Vary: X-Organization-Context

The CDN uses X-Organization-Context (not Authorization) as the cache key for per-location isolation. This means different JWT tokens for the same location share cache entries — token rotation doesn't bust the cache.

Always include X-Organization-Context

The X-Organization-Context header is required when using the CDN endpoint. Set it to the store/location org hashkey you want content for. Without it, responses may not be cached correctly. See the Authentication guide for details on location resolution.

Cache Invalidation

When content is updated in EngageHQ, caches are automatically invalidated:

  1. Redis cache is cleared immediately
  2. CDN invalidation is triggered for affected paths — fresh content is served within seconds
  3. ETag changes force clients to refetch on their next request

What Gets Invalidated

ActionInvalidated Paths
Content update/api/v1/public/content*, /api/v1/public/content/{slug}
Slug changeBoth old and new slug paths
Content deleteAffected content paths
Circular update/api/v1/public/circulars*, /api/v1/public/circulars/{id}
Offer update/api/v1/public/offers*

No action needed from API consumers

Cache invalidation is fully automatic. When an editor publishes a change, the CDN serves the updated content within seconds. You don't need to implement any cache-busting logic in your application.

Client-Side Caching Recommendations

For Content and Circulars

The CDN handles content freshness, so client-side caching can focus on UX. Use short TTLs to avoid unnecessary requests while relying on the CDN for the latest data:

javascript
class ContentCache {
  constructor() {
    this.cache = new Map();
  }

  async getContent(slug, token, orgId) {
    const cacheKey = `content:${slug}`;
    const cached = this.cache.get(cacheKey);

    // Use cached if less than 60 seconds old
    if (cached && Date.now() - cached.timestamp < 60000) {
      return cached.data;
    }

    const response = await fetch(
      `https://cdn.engagehq.retailsuccessplatform.com/api/v1/public/content/${slug}`,
      { headers: { 'Authorization': `Bearer ${token}`, 'X-Organization-Context': orgId } }
    );

    const data = await response.json();

    this.cache.set(cacheKey, {
      data: data.data,
      timestamp: Date.now()
    });

    return data.data;
  }
}

For Offers

Do not cache offers locally — they can expire or reach usage limits at any time:

javascript
// Good - always fetch fresh from CDN
async function getOffers(token, orgId) {
  const response = await fetch(
    'https://cdn.engagehq.retailsuccessplatform.com/api/v1/public/offers',
    { headers: { 'Authorization': `Bearer ${token}`, 'X-Organization-Context': orgId } }
  );
  return response.json();
}

// Bad - caching offers can show expired promotions
const cachedOffers = localStorage.getItem('offers');  // DON'T DO THIS

React Query / SWR Integration

React Query

javascript
import { useQuery } from '@tanstack/react-query';

function useContent(slug) {
  return useQuery({
    queryKey: ['content', slug],
    queryFn: () => fetchContent(slug),
    staleTime: 60 * 1000,      // Consider fresh for 60s
    cacheTime: 5 * 60 * 1000,  // Keep in cache for 5min
  });
}

function useOffers() {
  return useQuery({
    queryKey: ['offers'],
    queryFn: fetchOffers,
    staleTime: 0,              // Always refetch offers
    refetchOnWindowFocus: true, // Refresh when user returns
  });
}

SWR

javascript
import useSWR from 'swr';

function useContent(slug) {
  return useSWR(`/content/${slug}`, fetcher, {
    revalidateOnFocus: false,
    dedupingInterval: 60000,   // Dedupe requests for 60s
  });
}

function useOffers() {
  return useSWR('/offers', fetcher, {
    revalidateOnFocus: true,   // Always refetch on focus
    refreshInterval: 60000,    // Auto-refresh every 60s
  });
}

Performance Best Practices

Fetch content lists instead of individual items when possible:

javascript
// Good - one request for multiple items
const { data } = await fetch(
  'https://cdn.engagehq.retailsuccessplatform.com/api/v1/public/content?type=article&per_page=10'
);

// Bad - multiple requests for same data
for (const slug of slugs) {
  await fetch(`https://cdn.engagehq.retailsuccessplatform.com/api/v1/public/content/${slug}`);
}

2. Use Appropriate Page Sizes

Request only what you need:

javascript
// Homepage hero - just need 3 items
GET /content?type=hero&featured=true&per_page=3

// Full listing - use pagination
GET /content?type=article&per_page=20&page=1

3. Preload Critical Content

Preload content that users will likely need:

javascript
// In your router or page component
function prefetchRelated(contentType) {
  // Warm the cache for likely next pages
  queryClient.prefetchQuery(['content', contentType], () =>
    fetchContent({ type: contentType, per_page: 5 })
  );
}

4. Handle Stale Data Gracefully

Show cached data immediately, then update when fresh data arrives:

javascript
function ContentDisplay({ slug }) {
  const { data, isValidating } = useSWR(`/content/${slug}`);

  return (
    <div>
      {data && <Article content={data} />}
      {isValidating && <RefreshIndicator />}
    </div>
  );
}

Debugging Cache Issues

Check Response Headers

bash
curl -I "https://cdn.engagehq.retailsuccessplatform.com/api/v1/public/content/my-slug" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Organization-Context: $ORG_ID"

Look for:

  • Cache-Control — browser caching directives (max-age, stale-while-revalidate)
  • ETag — content hash for conditional requests
  • X-Cache — CDN cache status (Hit from cloudfront or Miss from cloudfront)
  • Age — seconds since the CDN cached this response (resets to 0 after invalidation)

Understanding the Age Header

The Age header tells you how long ago the CDN cached the response:

Age: 0      → Just fetched from origin (after invalidation or first request)
Age: 3600   → Cached 1 hour ago
Age: 86400  → Cached 24 hours ago (will be refreshed on next request)

After content is published, Age resets to 0 because the CDN invalidation forces a fresh fetch from the origin.

Force Cache Bypass

Add a cache-busting parameter for debugging (not for production):

javascript
// Development only
const response = await fetch(
  `https://cdn.engagehq.retailsuccessplatform.com/api/v1/public/content?_=${Date.now()}`
);

Changelog
DateChange
2026-02-23Added CloudFront CDN caching and Lambda@Edge integration guidance.
2026-01-28Expanded documentation.
2026-01-15Initial publication.

EngageHQ Public Content Delivery API