Get Your 1.6 Seconds Back - What AI Gets Wrong With MongoDB
We keep seeing AI put createIndex where it doesn't belong. At the top of the server file, one await per index, sitting right above app.listen, or worse, inside a route handler where it runs on every single request. Sometimes that code makes it to production, and when it does, MongoDB gets accused of being slow. So we measured what the habit actually costs.
createIndex is idempotent, so when the index already exists the server builds nothing and just says so. That's why this code survives review, it works. But every no-op is still a full round trip, and the awaits are serial. Here's the pattern, then the measurements at real scale.
The label below is the prompt we gave the AI to generate the block.
How to create MongoDB indexes at the top of an express server file before the routes.
Bad:
const app = express();
await db.collection('products').createIndex({ name: 1 });
await db.collection('products').createIndex({ category: 1, price: -1 });
await db.collection('users').createIndex({ email: 1 }, { unique: true });
await db.collection('orders').createIndex({ userId: 1, createdAt: -1 });
await db.collection('sessions').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
app.get('/search', async (req, res) => {
const results = await db.collection('products')
.find({ name: req.query.q })
.toArray();
res.json(results);
});
app.listen(3000);
Five indexes is a toy. A real app has more, so we built a realistic 30 collection commerce schema with 69 indexes, unique lookups, compound list-and-sort pairs, five TTLs, two sparse, one partial, one text index, and ran six boot strategies against both environments. Every collection held zero documents the whole time, so nothing ever got built. We were timing pure no-op round trips, which is exactly what your boot pays.
| Local Development | Production |
|---|---|
| MongoDB | 8.0 in Docker, same machine |
| ping | 1.07 ms |
First the steady state. Indexes exist, connection pool warm. This is what a boot pays once connections are reused:
| strategy | what it is | Local | Production |
|---|---|---|---|
| serial | 69 sequential await createIndex | 0.14 s (135 ms) | 1.63 s (1629 ms) |
| batched serial | 30 sequential createIndexes | 0.05 s (54 ms) | 0.62 s (617 ms) |
| parallel | all 69 in one Promise.all | 0.03 s (25 ms) | 0.23 s (225 ms) |
| pool of 8 | 69 calls, 8 in flight | 0.05 s (47 ms) | 0.19 s (193 ms) |
| pool of 16 | 69 calls, 16 in flight | 0.05 s (48 ms) | 0.11 s (106 ms) |
| batched + parallel | 30 createIndexes in one Promise.all | 0.02 s (16 ms) | 0.03 s (30 ms) |
The AI version spends 1.63 s (1629 ms) of every production boot confirming 69 indexes that already exist. The winner clears the entire schema in 0.03 s (30 ms), about one and a half pings for 69 index specs. And notice plain Promise.all is not the fix people think it is. All 69 calls fired at once still costs 0.23 s (225 ms), seven times the winner.
Steady state flatters everyone though, because a real boot starts from nothing. Container start, serverless cold start, plain node server.js. So we also ran each strategy as 7 independent processes, start node, connect, create, exit:
| strategy | index time | total with connect |
|---|---|---|
| serial | 1.62 s (1619 ms) | 2.08 s (2080 ms) |
| batched serial | 0.65 s (652 ms) | 1.11 s (1110 ms) |
| one chain per collection | 0.61 s (615 ms) | 1.29 s (1288 ms) |
| parallel | 0.57 s (571 ms) | 1.28 s (1283 ms) |
| pool of 16 | 1.01 s (1006 ms) | 1.42 s (1421 ms) |
| batched + parallel | 0.39 s (392 ms) | 0.82 s (819 ms) |
Both tables side by side, plus the option the benchmark could not run, doing no index work at boot at all:
| warm steady state | cold fresh process |
|---|---|
| serial at boot, the AI version | 1.63 s (1629 ms) |
| batched + parallel at boot | 0.03 s (30 ms) |
| script instead, index work at boot | 0 s |
| how much slower the AI version boots | 1.6 s |
The bottom two rows are the actual claim of this post. The fix is not a faster way to run indexes at boot, and every strategy in these tables is still the wrong place for the work. The fix is a separate script, which makes the app's index cost at boot zero, so the AI version boots 1.6 seconds slower than the script version. And warm or cold barely matters, serial was never using more than one connection, so it pays nearly the same either way. The batched numbers still earn their place for one reason, the script is itself a fresh process, so 0.39 s (392 ms) of index time, 0.82 s (819 ms) wall clock with connect, is exactly what node db/indexes.js costs on the day an index actually changes. That's the whole trade. 1.6 s off every single boot, paid back as 0.8 s once per index change.
The fresh process run also flipped one ranking. Pool of 16 was second best warm and second worst cold, because capping concurrency starves a cold pool of the parallelism it needs to warm up. A tuning choice that looks good in a benchmark loop can be the wrong one at the moment that matters.
The reason this mistake keeps shipping is in the next table. Same questions, answered by each environment:
| question | Local Development says | Production says |
|---|---|---|
| cost of the AI serial boot | 0.14 s (135 ms), invisible | 1.63 s (1629 ms), a visible stall |
| is plain Promise.all good enough | yes, 1.6x off the best | no, 7.4x off the best |
| spread between all six strategies, fresh process | 0.09 to 0.18 s (90 to 176 ms), everything within 2x | 0.39 to 1.62 s (392 to 1618 ms), a 4.1x spread |
The last row is the point. On a laptop every strategy lands inside the noise, so any ranking formed there is meaningless, including the one that says this doesn't matter. The decision is only visible in production, which is exactly where nobody is looking when the AI writes the code.
We also went in with a theory about why 69 parallel calls lose, and the data killed it. The guess was same-collection contention, products takes three index calls at once, they must be colliding. So we ran a collision-free arm, 30 chains, one per collection, same 69 commands:
| arm | commands | in flight | same-collection collisions | Production |
|---|---|---|---|---|
| all 69 in Promise.all | 69 | 69 | yes | 0.20 s (203 ms) |
| 30 chains, one per collection | 69 | 30 | no | 0.10 s (97 ms) |
| pool of 16 | 69 | 16 | yes | 0.11 s (106 ms) |
| batched + parallel | 30 | 30 | no | 0.03 s (30 ms) |
Collision-free at 30 in flight and collision-allowed at 16 in flight cost the same, so contention is not the mechanism. What the numbers actually support is two independent levers. Command count dominates, 30 commands land at 1.5 pings while 69 commands sit around 5 pings no matter how sensibly you schedule them. And concurrency stops paying above roughly 16 to 30 in flight, unbounded Promise.all is on the wrong side of that curve. The winner pulls both levers at once, batch per collection, then Promise.all the collections.
For completeness, the first deploy, where the 69 indexes genuinely don't exist and really get built:
| strategy | Production |
|---|---|
| serial | 2.17 s (2165 ms) |
| batched + parallel | 0.57 s (566 ms) |
| pool of 16 | 0.50 s (502 ms) |
The spread compresses because actual creation work dominates instead of round trips. It gets paid once. The no-op tables above get paid on every boot, forever, which is why they're the story.
And none of it belongs in your boot at all. The tables show what AI-written startup code costs today, and how the index script should be written so it's fast on the day you do run it:
How to batch MongoDB index creation into a standalone script using createIndexes per collection in parallel.
Good:
// db/indexes.js - never imported by the app. Run it when an index changes: node db/indexes.js
await Promise.all([
db.collection('products').createIndexes([
{ key: { name: 1 } },
{ key: { category: 1, price: -1 } }
]),
db.collection('users').createIndexes([
{ key: { email: 1 }, unique: true }
]),
db.collection('orders').createIndexes([
{ key: { userId: 1, createdAt: -1 } }
]),
db.collection('sessions').createIndexes([
{ key: { expiresAt: 1 }, expireAfterSeconds: 0 }
])
]);
console.log('indexes ready');
process.exit(0);
And server.js has no index code anywhere:
// server.js
app.get('/search', async (req, res) => {
const results = await db.collection('products').aggregate([
{ $match: { name: String(req.query.q) } },
{ $limit: 20 },
{ $project: { name: 1, price: 1, description: 1 } }
]).toArray();
res.json(results);
});
| don't | do |
|---|---|
| 69 commands, one at a time | 30 commands, all at once |
| 1.63 s (1629 ms) on every boot | 0 s at boot, 0.82 s (819 ms) script run when an index changes |
Two warnings if you re-run any of this, both earned the hard way. The first is that warmup is load-bearing. Measured with no warmup passes, the winner reads 0.18 s (181 ms) instead of 0.03 s (30 ms), six times too high, and the raw samples just keep falling, 348, 291, 1224, 181, 48, 44, 34, which is a connection pool warming up in front of the timer. Skip warmup and the numbers come out wrong, and possibly the ranking too. Our quoted numbers are medians of 15 runs after 5 discarded warmup passes, strategies interleaved so host variance spreads evenly.
The second is that we crashed a MongoDB container twice getting here. The first design gave each of the six strategies its own private 30 collections, and WiredTiger keeps a file per collection and per index, so 180 collections, around 410 indexes and 69 concurrent connections blew straight through the container's limit of 1024 open files. Panic, then a segfault on the retry. The fix was sharing one set of collections across arms, which is sound because warm no-ops mutate nothing, and raising the file limit to 64000. That one matters outside the benchmark too. File descriptors scale with collections times indexes times connections, and 1024 is not enough for a 30 collection app booting in parallel.
Two closing failure modes that no benchmark captures, because they only fire once. The route handler version, createIndex inside the endpoint itself, looks free for the same no-op reason, but point it at a fresh environment or a collection restored without its indexes and the first request starts a real index build that reads every document in the collection. Every request behind it issues the same createIndex, sees that exact build already in progress, and waits. The endpoint is down for the entire build and not a single error is thrown.
And the quiet one. Change an index's keys in code without setting an explicit name and you don't update the index, you create a second one, because the default name changes with the keys. The old index stays behind, taxing every write until someone audits the collection. A single script that lists every index you own is where you catch that. Sixty-nine createIndex calls scattered around a codebase is where you don't.