Announcing list response caching: Instant reads with TTL-based caching_
Cache list query responses in memory with a single parameter. Set a TTL, skip the database round-trip on repeated reads, and purge on demand when freshness matters.

Read-heavy workloads hit the same queries over and over. Leaderboards, product listings, dashboard feeds, and reference tables all follow the same pattern: the data changes infrequently, but the reads never stop. Every request still runs a full database query, even when the result hasn't changed since the last call.
Until now, the only option was to build your own caching layer on top of Appwrite. That meant extra infrastructure, invalidation logic, and another moving part to maintain.
Today, we are introducing TTL-based list response caching for Appwrite Databases. Pass a ttl parameter to any list endpoint, and Appwrite caches the response in memory. Repeated identical requests return the cached result instantly, without touching the database, until the TTL expires.
How it works
Add the ttl parameter (in seconds) to any listRows call. The first request executes normally and stores the result in an in-memory cache. Every subsequent identical request returns the cached response until the TTL expires.
const rows = await tablesDB.listRows({
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
queries: [
Query.equal('status', 'active'),
Query.limit(25)
],
ttl: 60 // Cache for 60 seconds
});
const rows = await tablesDB.listRows({
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
queries: [
sdk.Query.equal('status', 'active'),
sdk.Query.limit(25)
],
ttl: 60 // Cache for 60 seconds
});
rows = tables_db.list_rows(
database_id='<DATABASE_ID>',
table_id='<TABLE_ID>',
queries=[
Query.equal('status', 'active'),
Query.limit(25)
],
ttl=60 # Cache for 60 seconds
)
rows = tables_db.list_rows(
database_id: '<DATABASE_ID>',
table_id: '<TABLE_ID>',
queries: [
Appwrite::Query.equal('status', 'active'),
Appwrite::Query.limit(25)
],
ttl: 60 # Cache for 60 seconds
)
const rows = await tablesDB.listRows({
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
queries: [
Query.equal('status', 'active'),
Query.limit(25)
],
ttl: 60 // Cache for 60 seconds
});
$rows = $tablesDB->listRows(
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
queries: [
Query::equal('status', ['active']),
Query::limit(25)
],
ttl: 60 // Cache for 60 seconds
);
rows, err := tablesDB.ListRows(
"<DATABASE_ID>",
"<TABLE_ID>",
tablesDB.WithListRowsQueries([]string{
query.Equal("status", []interface{}{"active"}),
query.Limit(25),
}),
tablesDB.WithListRowsTtl(60), // Cache for 60 seconds
)
let rows = try await tablesDB.listRows(
databaseId: "<DATABASE_ID>",
tableId: "<TABLE_ID>",
queries: [
Query.equal("status", value: "active"),
Query.limit(25)
],
ttl: 60 // Cache for 60 seconds
)
val rows = tablesDB.listRows(
databaseId = "<DATABASE_ID>",
tableId = "<TABLE_ID>",
queries = listOf(
Query.equal("status", "active"),
Query.limit(25)
),
ttl = 60 // Cache for 60 seconds
)
let rows = tables_db.list_rows(
"<DATABASE_ID>",
"<TABLE_ID>",
Some(vec![
"equal(\"status\", [\"active\"])".to_string(),
"limit(25)".to_string(),
]),
None, // transaction_id
None, // total
Some(60), // ttl - Cache for 60 seconds
).await?;
RowList rows = await tablesDB.ListRows(
databaseId: "<DATABASE_ID>",
tableId: "<TABLE_ID>",
queries: new List<string> {
Query.Equal("status", new List<object> { "active" }),
Query.Limit(25)
},
ttl: 60 // Cache for 60 seconds
);
RowList rows = await tablesDB.listRows(
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
queries: [
Query.equal('status', 'active'),
Query.limit(25)
],
ttl: 60, // Cache for 60 seconds
);
tablesDB.listRows(
"<DATABASE_ID>",
"<TABLE_ID>",
List.of(
Query.equal("status", List.of("active")),
Query.limit(25)
),
null, // transactionId
null, // total
60, // ttl - Cache for 60 seconds
new CoroutineCallback<>((result, error) -> {
if (error != null) {
error.printStackTrace();
return;
}
System.out.println(result);
})
);
final rows = await tablesDB.listRows(
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
queries: [
Query.equal('status', 'active'),
Query.limit(25)
],
ttl: 60, // Cache for 60 seconds
);
let rows = try await tablesDB.listRows(
databaseId: "<DATABASE_ID>",
tableId: "<TABLE_ID>",
queries: [
Query.equal("status", value: "active"),
Query.limit(25)
],
ttl: 60 // Cache for 60 seconds
)
val rows = tablesDB.listRows(
databaseId = "<DATABASE_ID>",
tableId = "<TABLE_ID>",
queries = listOf(
Query.equal("status", "active"),
Query.limit(25)
),
ttl = 60 // Cache for 60 seconds
)
Set ttl between 1 and 86400 (24 hours). The default is 0, which means caching is disabled. The response includes an X-Appwrite-Cache header with value hit or miss, so you always know whether a response was served from cache.
Permission-aware by design
Caching does not compromise security. Each cached entry is scoped to the caller's authorization roles, so users with different permissions always receive their own results. There is no risk of one user seeing another user's data through the cache.
Built for stale-tolerant reads
This feature is designed for workloads where slightly stale data is acceptable. Row writes do not automatically invalidate the cache. If you update a row, cached responses will continue to serve the previous result until the TTL expires.
This is a deliberate trade-off. Automatic invalidation on every write would eliminate most of the performance benefit. Instead, you choose the TTL that fits your use case:
- Short TTLs (5 to 30 seconds) for feeds and dashboards where near-real-time matters
- Medium TTLs (60 to 300 seconds) for product listings, search results, and leaderboards
- Long TTLs (3600+ seconds) for reference data, configuration tables, and rarely changing content
Schema changes (adding or removing columns and indexes) invalidate cached entries automatically, so structural updates always take effect immediately.
Purge on demand
When you need fresh data immediately, you can purge the cache manually by calling updateTable with purge set to true.
await tablesDB.updateTable({
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
purge: true
});
tables_db.update_table(
database_id='<DATABASE_ID>',
table_id='<TABLE_ID>',
purge=True
)
tables_db.update_table(
database_id: '<DATABASE_ID>',
table_id: '<TABLE_ID>',
purge: true
)
await tablesDB.updateTable({
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
purge: true
});
$tablesDB->updateTable(
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
purge: true
);
tablesDB.UpdateTable(
"<DATABASE_ID>",
"<TABLE_ID>",
tablesDB.WithUpdateTablePurge(true),
)
let _ = try await tablesDB.updateTable(
databaseId: "<DATABASE_ID>",
tableId: "<TABLE_ID>",
purge: true
)
tablesDB.updateTable(
databaseId = "<DATABASE_ID>",
tableId = "<TABLE_ID>",
purge = true
)
tables_db.update_table(
"<DATABASE_ID>",
"<TABLE_ID>",
None, // name
None, // permissions
None, // row_security
None, // enabled
Some(true), // purge
).await?;
await tablesDB.UpdateTable(
databaseId: "<DATABASE_ID>",
tableId: "<TABLE_ID>",
purge: true
);
await tablesDB.updateTable(
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
purge: true,
);
tablesDB.updateTable(
"<DATABASE_ID>",
"<TABLE_ID>",
null, // name
null, // permissions
null, // rowSecurity
null, // enabled
true, // purge
new CoroutineCallback<>((result, error) -> {
if (error != null) {
error.printStackTrace();
return;
}
System.out.println(result);
})
);
This clears all cached list responses for that table in a single operation.
Available now
List response caching is available today on Appwrite Cloud.
More resources
Pass a ttl parameter (in seconds) to your listRows call. The first request runs normally and stores the response in an in-memory cache. Every subsequent identical request returns the cached response until the TTL expires. See the Appwrite Databases docs for full SDK examples.
Cache lookups key on the database, table, queries, and other request parameters together. Two requests must match on all of those to hit the same cache entry. Changing a query, limit, offset, or auth context produces a different key and triggers a fresh database fetch.
Use the built-in TTL cache for read-heavy endpoints where the data changes infrequently (leaderboards, product listings, dashboards, reference tables). Build your own cache only when you need richer invalidation, multi-region replication, or coordination across services that Appwrite doesn't cover.
Pick a TTL that matches how stale the data can be. A dashboard showing aggregated stats might tolerate 60 seconds, while a product catalog could comfortably cache for several minutes. Shorter TTLs cut staleness but reduce cache hit rate, so test against your traffic to find the right balance.
No, TTL-based caching is time-based, not invalidation-based. The cached response stays in memory until the TTL expires regardless of writes to the underlying rows. If you need fresher data right after a known write, use a shorter TTL or skip the ttl parameter for that read.
The cache is in-memory on Appwrite's serving layer, so behavior may vary by region and instance. For high cache hit rates, ensure your clients consistently send the same query parameters and target the same region. Treat TTL caching as a best-effort acceleration, not a strict shared cache.





