Take a plain Node service with a pg pool of 20. Let one agent request fan out into 8 tool calls that all want the database, then send 20 requests at once. You do not have 20 units of pressure. You have up to 160 query contenders for those 20 slots, before retries, before scale-out across Azure instances, before anybody opens a transaction and waits on a model.
This is why “parallel agents” often look fine in a test harness and then flatten Postgres under real traffic. My read is that the database is usually failing in old ways: connection queueing, open transactions, vacuum debt, and write amplification from too many indexes. The agents changed the clock speed.
That multiplication is the story.
The reason I like PGSimCity for this conversation is not the 3D gimmick. It is the framing. The project calls itself “How PostgreSQL Works, in 3D” and “a working model of the PostgreSQL engine.” That is the right mental move for an agent backend. You need to picture Postgres as bounded loops and queues: connections, workers, pages, row versions, cleanup, index maintenance. Once you do that, most “LLM workload” mysteries shrink into ordinary pressure.
Use PGSimCity as a map, not a spec
PGSimCity is honest about its limits. The homepage says it is an “Early, unreviewed prototype” and “almost certainly contains inaccuracies in both the model and explanations.” I would take that warning seriously.
So I would not use PGSimCity to win an argument about exact internals. I would use it to ask better operational questions:
where connections accumulate
where work waits
where cleanup falls behind
where writes multiply
That boundary matters. If you treat the city as documentation, you will overclaim. If you treat it as a picture of interacting queues and maintenance loops, it sharpens the judgment you actually need on Monday morning.
For a TypeScript service on Azure, that is already enough. You do not need a new agent-specific theory of databases. You need a cleaner picture of where concurrency is created, where it is buffered, and where background work can quietly become foreground latency.
Cap the harness above the pool
The first mistake is usually right above Postgres, not inside it.
A typical Node request already has concurrency at two layers: HTTP requests arrive in parallel, and inside each request your code can fan out work with Promise.all. Agent frameworks make that fan-out easier to trigger because planning one user task often means calling several tools, and the tools often want the same database. Add scale-out on Azure and each app instance usually brings its own pool. A per-process setting becomes a fleet-wide budget very quickly.
node-postgres documents the pool counters you should care about: totalCount, idleCount, and waitingCount. It also has a separate guide on pool sizing because the total connection budget is a deployment concern, not a single-process concern. That last sentence is the whole argument.
What matters in the picture is where concurrency multiplies before Postgres even sees it, and how slower queries feed back into even more overlap.
flowchart LR
A[HTTP requests] --> B[Agent planner]
B --> C[Parallel tool calls]
C --> D[Node pool queue]
D --> E[Postgres backends]
E --> F[Slower responses]
F --> G[More request overlap]
G --> B
E --> H[More row churn]
H --> I[Cleanup pressure]
I --> F
The tempting response is to raise the pool size or max_connections. I do not buy that as the first move. Bigger pools often just move the queue and give PostgreSQL more active backends to schedule. If four Azure instances each run a pool of 20, you have already budgeted for 80 database connections before migrations, admin sessions, or background jobs show up. If one request fans out into many DB-touching tools, the queue that hurts user latency is usually the one inside your app process.
So the first cap should usually sit above the pool: limit how many DB-touching tools can run in parallel per process, and make that number a deliberate fraction of pool capacity. The dangerous combination here is a property of your wiring.
Do not let transactions wait for the model
The second accelerated failure mode is the long transaction you did not mean to write.
In Node, this often happens through convenience. A helper starts a transaction, reads some rows, then an agent step calls an LLM or an HTTP tool, then more SQL happens later. Nobody intended to keep a transaction open across a network round-trip. The abstraction did it for them.
PostgreSQL’s routine vacuuming docs are the authority I would keep in view here. Vacuum exists because updates and deletes leave dead row versions behind under MVCC, and cleanup can be delayed by old transactions that still need to see earlier versions. The monitoring stats docs are the other half of the picture, because they expose session state, including idle in transaction.
If your app opens a transaction and then waits on a model call, you are paying twice. You hold a connection for longer, and you can delay cleanup of old row versions while that transaction remains open. Under agent fan-out, those costs stack fast because more requests overlap and more old versions accumulate.
This is boring advice. It is still the right advice: transaction scope should be brutally small, and model calls should never live inside it. Read what you need. Close the boundary. Call the model. Open a new, short write transaction only for the state change you are actually committing.
I would measure oldest transaction age early, not after the first vacuum incident. I would also page on idle in transaction sessions long before I spent time tuning prompts.
Background cleanup becomes foreground cost under agent write load
A lot of agent systems write more than they think they do.
Run state, tool results, message history, checkpoints, audit trails, caches, retry metadata, evaluation traces. Even if each write is small, the table churn can be high. That matters because autovacuum is only “background” while it is keeping up. Once updates and deletes outrun cleanup, the debt shows up in the hot path as more dead tuples, more page work, and a less forgiving cache.
This is where PGSimCity earns its keep as a mental model. Vacuum is a loop. Index maintenance is a loop. They are not magic janitors that clean up after your workload for free.
The other quiet multiplier is index count. Every extra index on a hot table adds write work. That can be a fine trade when the reads are valuable. It is a bad bargain when the index exists because it sounded harmless during an earlier phase of the product. Agent backends are especially good at exposing this mistake because they often create one or two very hot tables quickly: run records, events, messages, task state, tool outputs. Those tables get updated and filtered in many different ways, so teams add indexes defensively. Then traffic grows and the write path gets heavier than anyone expected.
The Monday move here is simple: pick the tables agents touch most often and ask two blunt questions. Is vacuum keeping up on them? Which indexes are actually earning their write cost?
Your first dashboard should be tiny and boring
If PGSimCity helps at all, it helps by forcing you to see bounded loops instead of AI mysticism. So the first dashboard for an agent-heavy service should stay small.
I would start with four things:
app-side pool wait time and waitingCount
active connections and oldest transaction age
dead tuple growth and last autovacuum on hot tables
indexes on write-heavy tables with low read payoff
That ordering is opinionated. I think it is the right one. If those four are unhealthy, prompt traces and planner graphs mostly show symptoms.
The PostgreSQL side of that dashboard can start with three queries from the stats views. They are not a full observability system. They are a good first pass.
-- Oldest transactions first.
select
pid,
state,
now() - xact_start as xact_age,
wait_event_type,
wait_event,
left(query, 120) as query
from pg_stat_activity
where datname = current_database()
and xact_start is not null
order by xact_start asc
limit 20;
-- Hot tables where dead tuples may be piling up.
select
relname,
n_live_tup,
n_dead_tup,
last_autovacuum,
autovacuum_count,
n_tup_ins,
n_tup_upd,
n_tup_del
from pg_stat_user_tables
order by n_dead_tup desc
limit 20;
-- Indexes on write-heavy tables with little visible read payoff.
select
ui.relname as table_name,
ui.indexrelname as index_name,
ui.idx_scan,
(ut.n_tup_ins + ut.n_tup_upd + ut.n_tup_del) as writes
from pg_stat_user_indexes ui
join pg_stat_user_tables ut using (relid)
order by writes desc, ui.idx_scan asc
limit 30;
None of this says model latency is irrelevant. It says your database loop gets veto power over everything above it. If the pool is backed up, transactions are aging, and dead tuples are climbing, the LLM is rarely the first problem worth chasing.
If an agent-heavy service is melting Postgres, assume you have rediscovered classic database pressure under a shorter fuse. Start where the loops are bounded: cap parallel tool work above the pool, keep transactions tiny, watch vacuum debt on the hot tables, and delete the indexes that only looked cheap when traffic was low.
Steal this
This is an illustrative TypeScript sketch for the first cap I would add in a Node service: limit DB-touching tool parallelism above the pool, and record how long the app waited for a client.
import { Pool, PoolClient } from 'pg';
const POOL_MAX = Number(process.env.PGPOOL_MAX ?? 20);
const DB_TOOL_PARALLELISM = Math.max(1, Math.floor(POOL_MAX / 2));
export const pool = new Pool({
max: POOL_MAX,
connectionTimeoutMillis: 2_000,
idleTimeoutMillis: 30_000,
});
class Semaphore {
private active = 0;
private queue: Array<() => void> = [];
constructor(private readonly limit: number) {}
async use<T>(fn: () => Promise<T>): Promise<T> {
if (this.active >= this.limit) {
await new Promise<void>((resolve) => this.queue.push(resolve));
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
this.queue.shift()?.();
}
}
}
const dbToolGate = new Semaphore(DB_TOOL_PARALLELISM);
export async function withPg<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const started = performance.now();
const client = await pool.connect();
const waitMs = performance.now() - started;
console.log('db_pool', {
waitMs,
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount,
});
try {
return await fn(client);
} finally {
client.release();
}
}
export async function runDbTouchingTool<T>(tool: () => Promise<T>): Promise<T> {
return dbToolGate.use(tool);
}
The shape is the payload: the pool is finite, the app-level harness is capped above it, and pool wait time becomes a metric instead of a surprise.