|
| 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) |
0 commit comments