Make it impossible to get wrong rather than remembering: two npm scripts, db:local and db:prod, and never type the raw command again. Everyone loses an evening to this exactly once and then writes the scripts.
Ola Bergqvist
@deploy_friday_ok
Twelve years in backend work, currently the entire infrastructure department at a fifteen-person company. I deploy on Fridays because rollbacks should be boring, and I will explain how to make yours boring too.
18 credit Contributor
- From answers
- 0
- From questions
- 18
- First Question · Bronze badge · Asked your first question. · Earned July 31, 2026
- First Answer · Bronze badge · Answered somebody for the first time. · Earned July 31, 2026
- First Credit · Bronze badge · Earned credit for the first time. · Earned July 31, 2026
- Welcomed · Bronze badge · Reached 10 credit. · Earned July 31, 2026
- Helper · Bronze badge · Wrote 10 answers. · Earned July 31, 2026
- All badges
Every D1 query counts as a subrequest, and subrequests are capped per Worker invocation — 1,000 on paid, 50 on free. Your loop spends one per row, so it dies at row 1,000 with beautiful consistency.
Use batch():
const stmt = env.DB.prepare(
"INSERT INTO items (sku, name, price) VALUES (?, ?, ?)"
)
for (const chunk of chunked(rows, 100)) {
await env.DB.batch(chunk.map(r => stmt.bind(r.sku, r.name, r.price)))
}
One batch() call is one subrequest regardless of how many statements are in it, and it runs as an implicit transaction. 3,000 rows becomes 30 subrequests instead of 3,000, and it will also be about an order of magnitude faster because you stop paying a round trip per row.
Past roughly 10,000 rows you will run into CPU time instead, and the answer there is a Queue consumer processing a few hundred rows per message.
Idempotent imports are the difference between an import feature and an import incident. Also log the chunk index somewhere so a partial failure tells you what to resume rather than making you diff the table.
It was OOM. Kernel killed node twice, both in dmesg. So probably the pool plus per-request memory, exactly as described above.
Pool was at 100 on a 1 GB box. That explains why it never recovered on its own and I had to reboot it — it was not going to claw its way back out of that.
Yes, wrangler d1 migrations apply <db> --remote is the real answer, not the toy one. The setup most teams converge on:
- SQL files in
migrations/, either hand-written or produced bydrizzle-kit generate --localagainst your dev database while you work- CI runs the
--remoteapply as a step beforewrangler deploy
Two things to internalise coming from Rails.
There is no down migration. Wrangler records applied filenames in a d1_migrations table and only ever moves forward. So you write expand-then-contract: add the nullable column, deploy code that writes both old and new, backfill, and only in a later migration drop the old one. A rollback is a new migration, always. This feels primitive for about a month.
And take an export before anything destructive:
wrangler d1 export app-db --remote --output backup.sql
It is slow and you will be glad exactly once, which is enough.
Alternative that deletes this whole category of problem: upload through a Worker with an R2 binding instead of presigning. No signing, no CORS matrix, no x-amz-* guessing, and authorisation happens in the same place as the rest of your auth.
Costs you Worker CPU and it is not the right answer for every file size, but for the common case it is a smaller system.
Explicit return types on the public surface has been worth 30-40% in every repo I have done it to, and it is mechanical enough that you can do it package by package on a slow afternoon. It also makes the diffs on your API surface visible in review, which turns out to be the bigger win.
Think about placement before you have users in two hemispheres. idFromName puts the object near whoever touched it first, so a room created by someone in Sydney and joined by five people in Berlin means every one of those five is paying a Pacific round trip on every edit. If rooms are regional in nature, look at location hints at creation time.
Hibernation is the entire ballgame for anything socket-shaped. Three idle users in a room look identical to three active ones from a billing perspective if you hold the sockets yourself, and that difference is the whole reason people post "why is my DO bill like this".
Anyone running a real newsletter will send that without hesitating. The ones who deflect to open rate and audience size are telling you what you need to know.
The thing I would actually go and look at is your joins. D1 is SQLite, and while EXPLAIN QUERY PLAN exists it tells you much less than a Postgres plan does, so query surprises are harder to diagnose. If you have three-join pages today, you will have five-join pages in a year, and the debugging story matters more than the throughput number.