How to EagerPurge PersistedQuery cache upon CF Publish
In AEM Headless world, PersistedQueries are the prefered way to share AEM content to head apps as graphql. The persistedquery gets (by default) cached at CDN and dispatcher for performance reasons. And out-of-box cache mechanism is TTL-based
Problem Statement
When content authors create CF to expose with graphql queries, the persisted queries are cached at CDN and dispatcher. When the author updates the content, the clientside DONOT receive the updated content immediately. Clients have to wait until cache TTL expires, to see refreshed content. Even worse problem, certain static content like i18n or header/footer links, dont change too frequent. But cache expires, making unecessary AEM trips.
Solution 1: Ignore CDN cache
The out-of-box cache for persisted queries offer only TTL based cacheing. To force queries to hit AEM, previously we appended a ?cachebuster query param to graphql url. So my graphql urls looked like this
https://mysite.com/graphql/execute.json/global/query1?cachebuster=timestampThe clientside was sending current timestamp at the end of urls. So the query always ignores CDN cache. This is not effective, we were not leveraging cacheing of persisted queries.
Solution 2: TTL-Based Cache → Event-Driven Cache
After working with Adobe through tickets, and confirming TTL was best available thru ootb, we built a custom solution.
We leverage the Surrogate key purge way of purging CDN cache. Below are the steps to purge
Step 1: Create purge API token
Follow this documentation here. To all AEM requests, we want to include a purge token that will be used to purge documents at CDN using API. This is explained with a video here.
Update the cdn.yaml for all environments with a surrogate authenticator and rule.
data:
authentication:
authenticators:
- name: purge-auth
type: purge
purgeKey1: ${{MY_PURGEKEY}}
rules:
- name: purge-auth-rule
when: { reqProperty: tier, equals: "publish" }
action:
type: authenticate
authenticator: purge-authStep 2: Create AIO runtime action by subscribing to CF Publish event
Get Saravana Prakash’s stories in your inbox
Join Medium for free to get updates from this writer.
The app.config.yaml looks like this:
application:
actions: actions
runtimeManifest:
packages:
purge-cache:
license: Apache-2.0
actions:
purge-cache:
function: actions/purge-cf-cache/index.mjs
web: 'no'
runtime: nodejs:22
inputs:
LOG_LEVEL: debug
# Please refer to the README.md file for more details on configuration and environment variables.
PUBLISH_HOST: ${PUBLISH_HOST}
PURGE_API_TOKEN: ${PURGE_API_TOKEN}
SURROGATE_KEY: ${CF_SURROGATE_KEY}
annotations:
require-adobe-auth: false
final: trueThe values for the environment variables come .env used during build and deployment into AIO. Like this
# .env
PUBLISH_HOST=https://myexample.com
PURGE_API_TOKEN=my-purgekey
CF_SURROGATE_KEY=graphql-allThe runtime action code looks like
// actions/purge-cache/index.mjs
import { Core } from '@adobe/aio-sdk';
import fetch from 'node-fetch';
/**
* Main entry for Adobe I/O Runtime (ESM)
*/
export async function main(params) {
const logger = Core.Logger('cf-purge', { level: params.LOG_LEVEL || 'info' });
logger.info('CF purge action invoked');
try {
// 1. Extract AEM event payload
if (!params.data) {
logger.warn('No event data found');
return {
statusCode: 200,
body: {
message: 'No event data found',
status: 'skipped',
},
};
}
let cfPath = null;
if (params.data.path) {
cfPath = params.data.path;
}
// 2. Filter only /content/dam/web
if (!cfPath || !cfPath.startsWith('/content/dam/web')) {
logger.info('CF outside /content/dam/web – skipping purge');
return {
statusCode: 200,
body: { message: 'No purge – CF outside /content/dam/web', cfPath },
};
}
// 3. Read env vars
const publishHost = params.PUBLISH_HOST;
const purgeToken = params.PURGE_API_TOKEN;
const surrogateKey = params.SURROGATE_KEY || 'graphql-all';
if (!publishHost || !purgeToken) {
logger.error('Missing PUBLISH_HOST or PURGE_API_TOKEN env vars');
return {
statusCode: 400,
body: { error: 'Missing PUBLISH_HOST or PURGE_API_TOKEN env vars' },
};
}
const purgeUrl = `${publishHost}`;
logger.info(`Calling PURGE ${purgeUrl} with Surrogate-Key=${surrogateKey} for CF=${cfPath}`);
// 4. Call CDN purge API
const res = await fetch(purgeUrl, {
method: 'PURGE',
headers: {
'X-AEM-Purge-Key': purgeToken,
'Surrogate-Key': surrogateKey,
'X-AEM-Purge': 'soft',
},
});
const text = await res.text();
logger.info(`Purge response status=${res.status}, body=${text}`);
return {
statusCode: res.status,
body: {
message: 'Purge executed',
cfPath,
status: res.status,
response: text,
},
};
} catch (e) {
logger.error('Error in CF purge action', e);
return {
statusCode: 400,
body: { error: e.message },
};
}
}This will hit CDN API to purge cache to resources bearing the surrogate key in their header.
Step 3: Subscribe to CF Publish event at AIO
Once the runtime action code is developed, deploy the runtime action to a aio project. Open the developer console, create new project and deploy the runtime action. From the Events, register to CF Published event. The registration details will look like this
Now whenver CF is published, this microservice will be triggered and action till hit CDN API to purge corresponding graphql cache.
Step 4: Update dispatcher configuration
So far, our setup will hit CDN API and brute clear matching caches for all CF publish event. But how does CDN know to purge specific graphql cache only? Here is where we fix dispatcher to append purge-key surrogate token to right resources
Into the *.vhost file, add code to include header attribute
# Add Surrogate-Key header for GraphQL requests referencing /content/dam/web assets, only for global/my module
<LocationMatch "^/graphql/execute\.json/global/my[^;%]*(;|%3B)path=/content/dam/web/.*$">
Header set Surrogate-Key "graphql-all"
</LocationMatch>This code will set a header attribute to graphql query responses matching regex pattern. In our project, we had multiple queries. Need purging only to specific queries. Hence prefixed expected queries with /global/my-* . So /graphql/execute.json/global/my-query1 will get purged when CF under /content/dam/web is published
Under *.farm, include Surrogate-key into response headers.
# Cache response headers next to a cached file. On the first request to
# an uncached resource, all headers matching one of the values found here
# are stored in a separate file, next to the cache file. On subsequent
# requests to the cached resource, the stored headers are added to the
# response.
# Note, that file globbing characters are not allowed here.
/headers {
"Cache-Control"
"Content-Disposition"
"Content-Type"
"Expires"
"Last-Modified"
"X-Content-Type-Options"
"x-aem-client-country"
"Surrogate-Key"
}Here only the last line was included Surrogate-Key . This will whitelist the Surrogate-key header attribute from dispatcher.
All changes done. You have successfully configured surrogate-key based cache clearance for persisted queries
How to validate
Before change, the state would be, when calling the graphql, query will return cached response. After updating the CF, still query will return stale content. Should wait for TTL to expire to see refreshed content.
After change, upon updating and publishing the CF, the query will render refreshed content.






