- 18×less memory per database52.7 MiB → 2.9 MiB
- 11–17×faster recovery after a backup service restart17–30 s → 1.5–1.7 s
- 0rows lost with Paris backups cut off113 s of writes
What your app sees
A Eurobase project can have its own SQLite databases. Declare one in your Wrangler config, add a migration, and query it through env.DB with the D1 API.
// wrangler.jsonc
{
"d1_databases": [
{ "binding": "DB", "database_name": "shop" }
]
}-- migrations/0001_init.sql
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL);// src/index.ts
export default {
async fetch(request, env) {
const { results } = await env.DB
.prepare('SELECT id, name FROM products WHERE price < ?')
.bind(20)
.all();
return Response.json(results);
}
};Nothing on this page changes that code. It’s about what answers the query.
Before: a JavaScript engine per database
Our Workers run on workerd, the open-source JavaScript runtime. It ships a solid SQLite layer, but exposes it only to JavaScript. So our first prototype gave every database its own workerd process, with a small script that passed each query to SQLite.
# one workerd process per database # 10 databases, 16 calls/s each # 82% reads, 18% writes, 20 min PSS 52.7 MiB each rows missing 0
It worked and it was isolated, but each database carried about 53 MiB, mostly for an engine whose only job was copying bytes. Sharing one process between many databases fixes the memory:
# 100 databases in one workerd process # one runs slow statements back to back anonymous 91–123 MiB in total others, req/30 s ~25,000 → 121 others, p99 4,160 ms
workerd runs one event loop per process, so one busy database stalls everyone else in it. On servers that different workspaces share, we won’t ship that.
- Process per databaseIsolated, but heavy
- Shared processLight, but noisy
- What we shipIsolated and light
After: keep SQLite, drop the engine
We didn’t rewrite SQLite. We wrote a small C++ server that links a copy of workerd’s SQLite code, with two changes of ours: a 30 s time limit per call, and a statement journal so that a statement stopped at that limit rolls back alone.
Each database still gets a process of its own. It receives each query in the format env.DB already sends, applies the same rules, such as no ATTACH and an allow list of PRAGMAs, counts rows read and written the same way, and answers in the same format.
# 100 databases on one server # one native process each # 70% reads, 30% writes # 1,936 calls/s in total RSS 680 MiB total, ~7 MiB each read p99 4 ms write p99 6 ms errors 0 rows missing 0 of 514,660
While load-testing it, we also made the backup service reopen databases 16 at a time instead of one by one, and keep each database’s backup chain instead of starting a new one. Together, that makes 100 databases writable again within 2 seconds of a restart:
# backup service restarted twice # 30 s apart # 100 databases writing before after all writable 29.8, 17.0 s 1.7, 1.5 s write p99 18.8 s 0.7 s writes refused 17 0 rows missing 0 0
Before and after
Lower is better in all three charts. For memory, Before and After each ran 10 databases under the same load on an x86 workstation, without the backup service. The chart shows median PSS. We rebuilt the first prototype for this run. An earlier version of this post compared two different runs: 30–41 MiB against about 7 MiB. The restart runs used 100 databases on a server the size of our app hosts, with 3 vCPUs and 4 GB of memory, before and after the backup service change.
PSS counts shared memory once. RSS counts the shared binary in every process, which is why the 100-database run shows about 7 MiB each. Sizing on that higher figure, one of our app servers has room for about 350 databases open at once, with no apps beside them.
What a process of its own gives you
- Your app never opens the database file. Only the database process runs your queries.
- A crash in your app or in the database doesn’t take the other down, and each database has its own memory limit.
- The database process has no network access and no credentials. Its socket is reachable only from your project’s app.
Backed up in Paris, with Amsterdam as fallback
Every committed change is copied continuously to object storage in Paris. Each copy is locked in compliance mode, so nobody, us included, can delete it before its lock ends. The history is kept for 7 days on the Free plan and 30 days on paid plans.
A write is confirmed once it’s committed on the server, and its upload starts at the same moment. If a server is lost, the confirmed writes not yet uploaded are lost with it: usually the last fraction of a second, more while the backup is behind.
If the Paris storage stops answering, the copies go to a second store in Amsterdam at once, and your writes go on. We tested it by cutting Paris off for 113 seconds while writing:
# 2 databases, one writing ~17 MiB/s writes accepted 57,807 copies to Amsterdam 682 + 855 # 2nd database's local files deleted rebuilt from both stores 770 ms rows checked afterwards 790,540 rows lost 0
A database has one writer at a time. Another server takes a database over only once the cloud provider’s API confirms that the old server was deleted, so two servers never write the same database.
Limits
- Up to 3 databases of 100 MB each per project on the Free plan, and 10 of 2 GB each on paid plans.
- On the Free plan, each database can read 5,000,000 rows and write 100,000 rows a day. After that, queries fail until 00:00 UTC.
- Each database can write up to 400 MiB a day on the Free plan and 2 GiB on paid plans. After that, reads keep working and writes resume at 00:00 UTC.
- If both Paris and Amsterdam stop answering, a database keeps writing until 128 MiB of changes on the Free plan, or 512 MiB on paid plans, are waiting to be backed up. Then it refuses writes until a store answers again.
- You can’t export a database or restore it to an earlier point yourself.



