Last week we introduced TTL-based list caching for Appwrite Databases. The announcement covered what the feature does and how to use it. This post is the follow-up: we put the cache under sustained load, measured it, and then broke the numbers down so you can decide whether the feature is worth wiring into your own read paths.
Instead of a synthetic micro-benchmark, we ran a realistic workload: a 10,000 row product catalog, a filtered listing query with sort and pagination, and 100,000 sequential requests per phase. The full benchmark is open source and lives at appwrite-community/ttl-benchmark, so you can reproduce the numbers on your own instance. Every measurement below comes from that run.
Explore the results interactively
We also built an interactive visualisation of this run at ttl-benchmark.appwrite.network. Pin percentiles, watch the live race simulator, and play with the scale slider while you read.
The workload
The benchmark lives in a standalone script that we wrote alongside this post. It has two phases and does nothing fancy between them:
- Phase 1: no cache. 100,000 calls to
listRowswithttl: 0. - Phase 2: TTL cache. 100,000 calls to the exact same query with
ttl: 300.
Both phases run against the same Appwrite instance, the same table, and the same network path. The only variable is the ttl parameter.
Schema
The seed script provisions a products table with fifteen columns covering the shapes you would expect in a product catalog: identifiers, categorical fields, numeric ranges, contact data, timestamps, and tag arrays.
| column | type | notes |
name | text | Brand + adjective + noun |
description | text | One-paragraph marketing copy |
sku | text | Unique identifier |
category | enum | 8 retail categories |
brand | text | One of 16 seeded brands |
price | float | 5.00 to 500.00 |
stock | integer | 0 to 500 |
inStock | boolean | derived from stock |
rating | float | 2.0 to 5.0 |
reviewCount | integer | 0 to 8000 |
manufacturerEmail | email | support contact |
manufacturerUrl | url | product page |
releasedAt | datetime | up to 4 years ago |
warehouseIp | ip | IPv4 origin |
tags | text (array) | up to 4 tags per row |
Most field values are derived from a seeded PRNG keyed on the row index, so two runs on different machines produce the same distribution of names, prices, categories, and ratings. Two fields intentionally drift between runs: row IDs (generated fresh by ID.unique()) and releasedAt (anchored to the current wall clock). Neither affects the query path we are measuring.
The query
The cached endpoint we exercise is a typical product listing: filter by category, threshold on rating, sort by popularity, and paginate.
const QUERY = [
Query.equal('category', 'electronics'),
Query.greaterThan('rating', 3.5),
Query.orderDesc('reviewCount'),
Query.limit(25)
];
This shape matters. It combines a categorical filter, a numeric threshold, a sort, and a limit, which is exactly the kind of query that benefits most from caching, because repeated identical requests are cheap to serve from memory but expensive to plan and execute against the database.
Enabling TTL caching
Turning TTL caching on is a single parameter on the existing listRows call. The SDK, endpoint, and query remain unchanged, and invalidation is handled server-side by the TTL window.
const rows = await tablesDB.listRows({
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
queries: [
Query.equal('category', 'electronics'),
Query.greaterThan('rating', 3.5),
Query.orderDesc('reviewCount'),
Query.limit(25)
],
ttl: 300
});
The first request executes a normal query and stores the result in memory. Every identical follow-up request served inside the TTL window returns the cached payload. Each response carries an X-Appwrite-Cache header of either hit or miss, so you can verify the cache is doing what you expect in production traffic.
The measurement harness
We want answers to three questions:
- How much faster does the average request get?
- What happens to the tail, especially p95 and p99?
- How much time do you save across a read-heavy session?
The harness is deliberately boring. One connection, sequential calls, performance.now() around each request, and a sorted array at the end for the percentiles. Running sequentially gives clean per-request timing without the noise that concurrent pipelining introduces.
async function runPhase({ ttl, iterations }) {
const samples = new Float64Array(iterations);
const startedAt = Date.now();
for (let i = 0; i < iterations; i++) {
const t0 = performance.now();
await db.listRows({
databaseId: DATABASE_ID,
tableId: TABLE_ID,
queries: QUERY,
...(ttl ? { ttl } : {})
});
samples[i] = performance.now() - t0;
}
return { samples, wall: Date.now() - startedAt };
}
After both phases finish, the script writes a markdown report to results/ with frontmatter that captures every parameter of the run. The same report is the source of the numbers you are about to read.
Results
Here is the final output from the benchmark, run against a locally hosted Appwrite instance with the TTL feature enabled:
And the same data in a table, for readers who prefer it that way:
| metric | no cache | ttl cache |
total wall | 22m 43.4s | 10m 44.5s |
avg / req | 13.626 ms | 6.440 ms |
min | 10.783 ms | 4.146 ms |
p50 | 13.187 ms | 6.108 ms |
p90 | 15.173 ms | 7.862 ms |
p95 | 16.450 ms | 8.966 ms |
p99 | 21.303 ms | 12.527 ms |
max | 118.957 ms | 75.173 ms |
req / sec | 73 | 155 |
Reading the numbers
The headline is simple: average latency dropped from 13.626 ms to 6.440 ms, a 2.12x speedup and a 52.7% reduction. But averages hide interesting detail, so it is worth looking at the rest of the distribution.
Throughput doubles
The no-cache phase sustained 73 requests per second on a single connection. The cached phase sustained 155. That ratio is exactly what the latency numbers predict, and it means a read-heavy endpoint can absorb roughly twice the traffic on the same Appwrite instance, with no client-side changes beyond the ttl parameter.
The tail compresses
Averages and medians improve a lot. The tail improves too, but not by the same multiplier.
- p95: 16.450 ms to 8.966 ms, a 1.83x speedup.
- p99: 21.303 ms to 12.527 ms, a 1.70x speedup.
This is expected. The cache removes query planning, execution, and permission evaluation from the hot path, which are the dominant cost for the average call. What remains in the tail is network, TLS, and the occasional GC pause, none of which caching can remove.
Minimums reveal the floor
The fastest cached response came in at 4.146 ms. That is the practical lower bound on this workload: network round trip, TLS handshake reuse, JSON decode on the client, and a memory read on the server.
Wall clock is the number your users feel
The no-cache phase took 22 minutes 43 seconds to complete 100,000 requests. The cached phase took 10 minutes 44 seconds. The difference, 11 minutes 58 seconds, is time Appwrite did not spend executing the same query a hundred thousand times.
For a dashboard that polls a leaderboard every few seconds across a few thousand concurrent users, that difference translates directly into lower latency for every reader and a noticeably snappier feel on the client side.
Build fast, scale faster
Backend infrastructure and web hosting built for developers who ship.
Start for free
Open source
Support for over 13 SDKs
Managed cloud solution
Caveats worth stating
No benchmark is free of context, and this one has three worth calling out.
- The cache hits on identical queries only. Change the category, the limit, or the sort direction and you are in cache-miss territory until the new key warms. In production, bucket your queries so that a small number of keys cover the hot paths.
- Writes do not invalidate the cache. That is deliberate: automatic invalidation on every row write would eliminate most of the performance benefit. Pick a TTL that matches your tolerance for stale data, or call
updateTablewithpurge: truewhen you need a forced refresh. - Local and cloud will differ. These numbers come from a local instance. Cloud tenants will see different absolute values because of network path and cross-region effects, but the shape of the curve (average cuts roughly in half, tail compresses a bit less) holds up consistently in our testing.
Purging the cache on demand
When you know the underlying data has changed and stale responses are not acceptable, you can clear all cached list responses for a table in a single call:
await tablesDB.updateTable({
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
purge: true
});
This is the right escape hatch after a bulk import, a moderator action on a product listing, or any other event where your application knows a table changed and wants subsequent reads to reflect that immediately.
When the feature pays off
Based on this run and the workloads we have instrumented since the feature shipped, TTL caching is a clear win when three conditions hold:
- The same query shape fires more than a handful of times per TTL window.
- Stale responses within the window are acceptable, or the window is short enough that staleness is bounded.
- The query is non-trivial, meaning it has filters, sorting, or a large result set. Trivial queries against small tables are already fast and see smaller gains.
The catalog listing in this benchmark satisfies all three. So do leaderboards, dashboard feeds, reference tables, configuration stores, and most public product pages.
Try it yourself
The full benchmark, including the seeder, the product generator, and the markdown report writer, runs with a single command once you set your endpoint, project id, and API key. Point it at any Appwrite instance that has TTL caching enabled and you will get your own numbers in under forty minutes.
node setup.js # provisions the database, table, and 10k rows
node bench.js # runs both phases and writes results/<timestamp>.md
If you want to explore further:






