Skip to content

Commit 296ce9e

Browse files
Merge pull request #2878 from appwrite/ttl-docs
2 parents 7cf8851 + 6f728ac commit 296ce9e

6 files changed

Lines changed: 1335 additions & 0 deletions

File tree

.optimize-cache.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@
183183
"images/blog/announcing-init-faster-smoother-better/init-swag.png": "2894ba9370588ff92a0d94ddb410e1700ae368834391603f0bceadf57ac89fab",
184184
"images/blog/announcing-init-faster-smoother-better/init-ticket.png": "fe4e16ef27d3fcba378c52882ce3458aab3f1de84cb183d39db577e5264ef905",
185185
"images/blog/announcing-inversion-queries/cover.png": "232f806b8b655f469cb5398ba3abce2074e959d2fb49b9782b1889b22f1ee16e",
186+
"images/blog/announcing-list-cache-ttl/cover.png": "ca1554dc34d1222b86ccc295252af8e07b2f635b9b10e1227a21fff81138e409",
186187
"images/blog/announcing-new-push-notifications-features/cover.png": "a0c758cf6c8a95e09a0d2ca562b0775a50d34a4d691d675cda70e44ad21805ac",
187188
"images/blog/announcing-opt-in-relationship-loading/cover.png": "e16cc16ea6d968b29af19bcd6274741141584a7efe5e1bb18be19b77c3a380c8",
188189
"images/blog/announcing-phone-OTP-pricing/cover.png": "598d55359ca4cb2b46846a8fd76b1f051be7c5f3199b50ffa92a28e84e5f3d67",
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
---
2+
layout: post
3+
title: "Announcing list response caching: Instant reads with TTL-based caching"
4+
description: 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.
5+
date: 2026-04-17
6+
cover: /images/blog/announcing-list-cache-ttl/cover.png
7+
timeToRead: 4
8+
author: jake-barnby
9+
category: announcement
10+
featured: false
11+
callToAction: true
12+
---
13+
14+
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.
15+
16+
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.
17+
18+
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.
19+
20+
# How it works
21+
22+
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.
23+
24+
{% multicode %}
25+
```client-web
26+
const rows = await tablesDB.listRows({
27+
databaseId: '<DATABASE_ID>',
28+
tableId: '<TABLE_ID>',
29+
queries: [
30+
Query.equal('status', 'active'),
31+
Query.limit(25)
32+
],
33+
ttl: 60 // Cache for 60 seconds
34+
});
35+
```
36+
```server-nodejs
37+
const rows = await tablesDB.listRows({
38+
databaseId: '<DATABASE_ID>',
39+
tableId: '<TABLE_ID>',
40+
queries: [
41+
sdk.Query.equal('status', 'active'),
42+
sdk.Query.limit(25)
43+
],
44+
ttl: 60 // Cache for 60 seconds
45+
});
46+
```
47+
```server-python
48+
rows = tables_db.list_rows(
49+
database_id='<DATABASE_ID>',
50+
table_id='<TABLE_ID>',
51+
queries=[
52+
Query.equal('status', 'active'),
53+
Query.limit(25)
54+
],
55+
ttl=60 # Cache for 60 seconds
56+
)
57+
```
58+
```server-ruby
59+
rows = tables_db.list_rows(
60+
database_id: '<DATABASE_ID>',
61+
table_id: '<TABLE_ID>',
62+
queries: [
63+
Appwrite::Query.equal('status', 'active'),
64+
Appwrite::Query.limit(25)
65+
],
66+
ttl: 60 # Cache for 60 seconds
67+
)
68+
```
69+
```server-deno
70+
const rows = await tablesDB.listRows({
71+
databaseId: '<DATABASE_ID>',
72+
tableId: '<TABLE_ID>',
73+
queries: [
74+
Query.equal('status', 'active'),
75+
Query.limit(25)
76+
],
77+
ttl: 60 // Cache for 60 seconds
78+
});
79+
```
80+
```server-php
81+
$rows = $tablesDB->listRows(
82+
databaseId: '<DATABASE_ID>',
83+
tableId: '<TABLE_ID>',
84+
queries: [
85+
Query::equal('status', ['active']),
86+
Query::limit(25)
87+
],
88+
ttl: 60 // Cache for 60 seconds
89+
);
90+
```
91+
```server-go
92+
rows, err := tablesDB.ListRows(
93+
"<DATABASE_ID>",
94+
"<TABLE_ID>",
95+
tablesDB.WithListRowsQueries([]string{
96+
query.Equal("status", []interface{}{"active"}),
97+
query.Limit(25),
98+
}),
99+
tablesDB.WithListRowsTtl(60), // Cache for 60 seconds
100+
)
101+
```
102+
```server-swift
103+
let rows = try await tablesDB.listRows(
104+
databaseId: "<DATABASE_ID>",
105+
tableId: "<TABLE_ID>",
106+
queries: [
107+
Query.equal("status", value: "active"),
108+
Query.limit(25)
109+
],
110+
ttl: 60 // Cache for 60 seconds
111+
)
112+
```
113+
```server-kotlin
114+
val rows = tablesDB.listRows(
115+
databaseId = "<DATABASE_ID>",
116+
tableId = "<TABLE_ID>",
117+
queries = listOf(
118+
Query.equal("status", "active"),
119+
Query.limit(25)
120+
),
121+
ttl = 60 // Cache for 60 seconds
122+
)
123+
```
124+
```server-rust
125+
let rows = tables_db.list_rows(
126+
"<DATABASE_ID>",
127+
"<TABLE_ID>",
128+
Some(vec![
129+
"equal(\"status\", [\"active\"])".to_string(),
130+
"limit(25)".to_string(),
131+
]),
132+
None, // transaction_id
133+
None, // total
134+
Some(60), // ttl - Cache for 60 seconds
135+
).await?;
136+
```
137+
```server-dotnet
138+
RowList rows = await tablesDB.ListRows(
139+
databaseId: "<DATABASE_ID>",
140+
tableId: "<TABLE_ID>",
141+
queries: new List<string> {
142+
Query.Equal("status", new List<object> { "active" }),
143+
Query.Limit(25)
144+
},
145+
ttl: 60 // Cache for 60 seconds
146+
);
147+
```
148+
```server-dart
149+
RowList rows = await tablesDB.listRows(
150+
databaseId: '<DATABASE_ID>',
151+
tableId: '<TABLE_ID>',
152+
queries: [
153+
Query.equal('status', 'active'),
154+
Query.limit(25)
155+
],
156+
ttl: 60, // Cache for 60 seconds
157+
);
158+
```
159+
```server-java
160+
tablesDB.listRows(
161+
"<DATABASE_ID>",
162+
"<TABLE_ID>",
163+
List.of(
164+
Query.equal("status", List.of("active")),
165+
Query.limit(25)
166+
),
167+
null, // transactionId
168+
null, // total
169+
60, // ttl - Cache for 60 seconds
170+
new CoroutineCallback<>((result, error) -> {
171+
if (error != null) {
172+
error.printStackTrace();
173+
return;
174+
}
175+
System.out.println(result);
176+
})
177+
);
178+
```
179+
```client-flutter
180+
final rows = await tablesDB.listRows(
181+
databaseId: '<DATABASE_ID>',
182+
tableId: '<TABLE_ID>',
183+
queries: [
184+
Query.equal('status', 'active'),
185+
Query.limit(25)
186+
],
187+
ttl: 60, // Cache for 60 seconds
188+
);
189+
```
190+
```client-apple
191+
let rows = try await tablesDB.listRows(
192+
databaseId: "<DATABASE_ID>",
193+
tableId: "<TABLE_ID>",
194+
queries: [
195+
Query.equal("status", value: "active"),
196+
Query.limit(25)
197+
],
198+
ttl: 60 // Cache for 60 seconds
199+
)
200+
```
201+
```client-android-kotlin
202+
val rows = tablesDB.listRows(
203+
databaseId = "<DATABASE_ID>",
204+
tableId = "<TABLE_ID>",
205+
queries = listOf(
206+
Query.equal("status", "active"),
207+
Query.limit(25)
208+
),
209+
ttl = 60 // Cache for 60 seconds
210+
)
211+
```
212+
{% /multicode %}
213+
214+
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.
215+
216+
# Permission-aware by design
217+
218+
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.
219+
220+
# Built for stale-tolerant reads
221+
222+
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.
223+
224+
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:
225+
226+
- **Short TTLs (5 to 30 seconds)** for feeds and dashboards where near-real-time matters
227+
- **Medium TTLs (60 to 300 seconds)** for product listings, search results, and leaderboards
228+
- **Long TTLs (3600+ seconds)** for reference data, configuration tables, and rarely changing content
229+
230+
Schema changes (adding or removing columns and indexes) invalidate cached entries automatically, so structural updates always take effect immediately.
231+
232+
# Purge on demand
233+
234+
When you need fresh data immediately, you can purge the cache manually by calling `updateTable` with `purge` set to `true`.
235+
236+
{% multicode %}
237+
```server-nodejs
238+
await tablesDB.updateTable({
239+
databaseId: '<DATABASE_ID>',
240+
tableId: '<TABLE_ID>',
241+
purge: true
242+
});
243+
```
244+
```server-python
245+
tables_db.update_table(
246+
database_id='<DATABASE_ID>',
247+
table_id='<TABLE_ID>',
248+
purge=True
249+
)
250+
```
251+
```server-ruby
252+
tables_db.update_table(
253+
database_id: '<DATABASE_ID>',
254+
table_id: '<TABLE_ID>',
255+
purge: true
256+
)
257+
```
258+
```server-deno
259+
await tablesDB.updateTable({
260+
databaseId: '<DATABASE_ID>',
261+
tableId: '<TABLE_ID>',
262+
purge: true
263+
});
264+
```
265+
```server-php
266+
$tablesDB->updateTable(
267+
databaseId: '<DATABASE_ID>',
268+
tableId: '<TABLE_ID>',
269+
purge: true
270+
);
271+
```
272+
```server-go
273+
tablesDB.UpdateTable(
274+
"<DATABASE_ID>",
275+
"<TABLE_ID>",
276+
tablesDB.WithUpdateTablePurge(true),
277+
)
278+
```
279+
```server-swift
280+
let _ = try await tablesDB.updateTable(
281+
databaseId: "<DATABASE_ID>",
282+
tableId: "<TABLE_ID>",
283+
purge: true
284+
)
285+
```
286+
```server-kotlin
287+
tablesDB.updateTable(
288+
databaseId = "<DATABASE_ID>",
289+
tableId = "<TABLE_ID>",
290+
purge = true
291+
)
292+
```
293+
```server-rust
294+
tables_db.update_table(
295+
"<DATABASE_ID>",
296+
"<TABLE_ID>",
297+
None, // name
298+
None, // permissions
299+
None, // row_security
300+
None, // enabled
301+
Some(true), // purge
302+
).await?;
303+
```
304+
```server-dotnet
305+
await tablesDB.UpdateTable(
306+
databaseId: "<DATABASE_ID>",
307+
tableId: "<TABLE_ID>",
308+
purge: true
309+
);
310+
```
311+
```server-dart
312+
await tablesDB.updateTable(
313+
databaseId: '<DATABASE_ID>',
314+
tableId: '<TABLE_ID>',
315+
purge: true,
316+
);
317+
```
318+
```server-java
319+
tablesDB.updateTable(
320+
"<DATABASE_ID>",
321+
"<TABLE_ID>",
322+
null, // name
323+
null, // permissions
324+
null, // rowSecurity
325+
null, // enabled
326+
true, // purge
327+
new CoroutineCallback<>((result, error) -> {
328+
if (error != null) {
329+
error.printStackTrace();
330+
return;
331+
}
332+
System.out.println(result);
333+
})
334+
);
335+
```
336+
{% /multicode %}
337+
338+
This clears all cached list responses for that table in a single operation.
339+
340+
# Available now
341+
342+
List response caching is available today on Appwrite Cloud.
343+
344+
# More resources
345+
346+
- [Rows: Cache list responses](/docs/products/databases/rows#cache-list-responses)
347+
- [Pagination: Cache list responses](/docs/products/databases/pagination#cache-list-responses)
348+
- [Announcing Full Schema Creation: Provision complete tables in one atomic call](/blog/post/announcing-full-schema-creation)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
layout: changelog
3+
title: "TTL-based list response caching"
4+
date: 2026-04-17
5+
cover: /images/blog/announcing-list-cache-ttl/cover.png
6+
---
7+
8+
You can now cache `listRows` responses by passing a `ttl` parameter (in seconds). The first request executes normally and stores the result in an in-memory cache. Subsequent identical requests return the cached response instantly until the TTL expires. The cache is permission-aware, so users with different roles never see each other's cached data.
9+
10+
Set `ttl` between `1` and `86400` (24 hours). The default is `0` (caching disabled). Row writes do not invalidate the cache. To force a purge, call `updateTable` with `purge` set to `true`.
11+
12+
{% arrow_link href="/blog/post/announcing-list-cache-ttl" %}
13+
Read the full announcement
14+
{% /arrow_link %}

0 commit comments

Comments
 (0)