src/persistence/WriteQueue.ts — not crushing the database
src/persistence/WriteQueue.ts — not crushing the database
In one sentence: a waiting line for database writes that limits how many run at once, and throws away the oldest jobs if the line grows too long.
Size: 84 lines. Depends on: nothing.
Why this file exists
Here is the situation it prevents.
A hundred lobbies can finish within the same second — especially in a stress test, where they all start together and take roughly the same time. Without a queue, that means 100 database transactions fired simultaneously.
What goes wrong:
- The connection pool runs out. Prisma opens perhaps 10 connections; the other 90 writes queue inside the driver where nothing can see or control them.
- Everything slows down, and slow database work occupies the event loop.
- The game tick starts running late — because Node has one thread, and it is busy handling database callbacks.
So a database hiccup turns into a stuttering game for players who are not even involved. This queue makes that impossible.
Two limits do the work:
- Concurrency — at most 10 writes running at a time.
- Depth — at most 5,000 jobs waiting. Beyond that, the oldest are dropped.
The second limit is the one people forget. An unbounded queue does not fail; it grows until the process runs out of memory, which is a far worse outcome than losing some history.
The code, line by line
1. Options (lines 12–19)
export interface WriteQueueOptions {
concurrency: number;
maxQueueDepth: number;
onError?: (err: unknown) => void;
onDrop?: (dropped: number) => void;
}
onError and onDrop are optional callbacks. The queue does not decide what to
do about a failure — it reports it, and the caller logs it. That keeps this class
free of any logging opinion.
2. Fields (lines 21–33)
export class WriteQueue {
private readonly pending: Array<() => Promise<unknown>> = [];
private inFlight = 0;
private readonly options: WriteQueueOptions;
readonly stats = { enqueued: 0, completed: 0, failed: 0, dropped: 0 };
Array<() => Promise<unknown>> deserves unpacking. It is an array of
functions, each of which returns a promise.
The distinction matters enormously. We store the function, not the promise itself. A promise starts running the moment it is created — so storing promises would mean all 100 writes start immediately, which is exactly what we are trying to avoid. Storing functions means nothing starts until we call it.
stats is public and appears on /metrics. dropped climbing is a real alarm.
3. The constructor (lines 35–42)
constructor(options: Partial<WriteQueueOptions> = {}) {
this.options = {
concurrency: options.concurrency ?? 10,
maxQueueDepth: options.maxQueueDepth ?? 5000,
onError: options.onError,
onDrop: options.onDrop,
};
}
Partial<WriteQueueOptions> means every field is optional, so new WriteQueue()
works and gets sensible defaults.
4. push — adding a job (lines 53–62)
push(job: () => Promise<unknown>): void {
if (this.pending.length >= this.options.maxQueueDepth) {
this.pending.shift();
this.stats.dropped++;
this.options.onDrop?.(this.stats.dropped);
}
this.pending.push(job);
this.stats.enqueued++;
this.drain();
}
: void — the return type is nothing, on purpose. The caller gets no promise
to await, so there is no way to accidentally block the game on a database write.
The API prevents the mistake rather than documenting it.
this.pending.shift() removes the oldest waiting job when the queue is
full.
Why drop the oldest rather than refusing the newest? Because a huge backlog means the database is unwell. The oldest entries have been waiting longest and are least likely to still matter; the newest result is the one someone might be looking at right now.
Either way something is lost. The point is that it is lost deliberately, with a counter, instead of the process quietly growing until it dies.
this.options.onDrop?.(…) — optional chaining. Call it only if it was supplied.
5. drain — the engine (lines 64–84)
private drain(): void {
while (this.inFlight < this.options.concurrency && this.pending.length > 0) {
const job = this.pending.shift()!;
this.inFlight++;
job()
.then(() => { this.stats.completed++; })
.catch((err) => {
this.stats.failed++;
this.options.onError?.(err);
})
.finally(() => {
this.inFlight--;
this.drain();
});
}
}
The loop condition is the whole policy: keep starting jobs while there is room and work waiting.
this.inFlight < concurrency— room for another.this.pending.length > 0— something to do.
const job = this.pending.shift()! — take the oldest waiting job. The ! tells
TypeScript "this is definitely not undefined", which is true because the loop
condition already checked the array is not empty.
job() — now the work starts. Not before.
Then three handlers:
.then(…)— it worked, count it..catch(…)— it failed. Count it and carry on. A failed database write must never crash the server; the game does not depend on it..finally(…)— runs either way. Free the slot and calldrain()again.
That last line is the clever part. Each finishing job pulls the next one in. There is no timer and no polling loop — the queue drives itself, and goes completely quiet when there is nothing to do.
6. flush — waiting for the backlog (lines 87–94)
async flush(timeoutMs = 10_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while ((this.pending.length > 0 || this.inFlight > 0) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
return this.pending.length === 0 && this.inFlight === 0;
}
Used in exactly two places: graceful shutdown (give pending results a chance to land before closing the connection) and tests (wait for the save, then check it happened).
Never called during gameplay — this is the one method that waits.
Date.now() < deadline — the timeout matters. If the database is completely down,
shutdown must not hang forever. Returns true if everything drained, false if
it gave up.
The polling loop checks every 25 ms. Slightly crude, but this runs at most twice in a process's life.
How it is used
Wiring, in server.ts:
const writeQueue = new WriteQueue({
concurrency: 10,
maxQueueDepth: 5000,
onError: (err) => console.error('[persistence] write failed', err),
onDrop: (total) => console.warn(`[persistence] shed backlog, ${total} dropped total`),
});
Use, in PersistenceSubscriber.ts:
this.queue.push(() => this.repository.saveGameResult(job));
One line, no await. The game continues immediately.
Proved by test
it('caps concurrency so a burst cannot exhaust the connection pool', async () => {
const queue = new WriteQueue({ concurrency: 3 });
let active = 0;
let peak = 0;
for (let i = 0; i < 25; i++) {
queue.push(async () => {
active++;
peak = Math.max(peak, active);
await tick();
active--;
});
}
await queue.flush(5000);
expect(peak).toBeLessThanOrEqual(3); // never more than 3 at once
expect(queue.stats.completed).toBe(25); // and all 25 still ran
});
Twenty-five jobs pushed at once; never more than three running; all twenty-five complete. There are also tests proving a throwing job does not stop the others, and that the queue sheds rather than growing past its depth limit.
If an interviewer asks
"Why do you need a queue at all?"
"A hundred lobbies can finish in the same second. Firing a hundred transactions at once exhausts the connection pool, and since Node has one thread, the resulting database work makes the game tick run late — so a database hiccup becomes a stuttering game for uninvolved players."
"Why store functions instead of promises?"
"A promise starts running the moment it's created. Storing promises would mean all hundred writes begin immediately, which is exactly what the queue exists to prevent. Storing functions means nothing starts until the queue calls it."
"Why drop jobs instead of queueing them all?"
"An unbounded queue doesn't fail cleanly — it grows until the process runs out of memory. Losing some history is a much better outcome than the server dying, and the drop count is on
/metricsso it's visible rather than silent."