How Gurwi and Clatri scale under real load
The exact route of a request across Cloudflare, Railway, and Supabase
When Camilo sends a chat message in Clatri, his phone first asks DNS where the API lives. [Cloudflare] answers with one of its own edge addresses, so the request lands at a Cloudflare data center near him. That data center terminates TLS, applies its proxy rules and forwards the request to the single public hostname Railway gave the service.
[Cloudflare] never sees the replicas. It knows one origin, the Railway domain, and it sends everything there whether the service runs one copy or ten. Adding a replica in Railway changes nothing in Cloudflare's configuration, because Cloudflare acts as a DNS and proxy layer in this setup and does no load balancing between containers.
[Railway] picks the replica. Its edge router receives the request on the service domain and hands it to one of the healthy instances of the API. That instance runs the application code, and when it needs data it opens (or reuses) a connection to [Supabase], where Postgres executes the query and returns rows.
Each hop owns a different failure. A DNS or certificate problem lives in Cloudflare, a crashed or slow container lives in Railway, and a slow query or a full connection pool lives in Supabase. Knowing which hop owns a symptom is what keeps a debugging session from turning into guesswork.

Why two API replicas exist: zero-downtime rollouts over raw compute demand
Scaling comes in two directions. Vertical scaling gives one instance more CPU and RAM, and horizontal scaling runs more identical instances side by side. Both Gurwi and Clatri run their API horizontally with 2 replicas in [Railway], and the telemetry shows that compute demand did not force that choice.
Clatri's API averages 0.02 vCPU out of 32 available [Railway]. Gurwi's peak traffic is 2 chats per minute. Neither number comes close to saturating a single container, so a second replica adds almost nothing in raw capacity.
- s
- Blueprint of the Railway edge router sending requests to two API replicas
- RAILWAY · ROLLING UPDATE
- Edge
- router
- API v1
- API v2
- Serving
- Starting
- Healthy
- Draining
- Stopped
- Standby
- Healthcheck 120 s · drain 60 s
- Active in-flight requests
- Deploy v2
- Reset
- Deploying…
- v1 serves 100% of traffic
- Healthcheck: v2 warming up
- Traffic shifted · draining v1
- Deploy complete · v2 serves
The second replica pays off during deploys. When a new version ships, Railway starts the new containers and waits for them to pass the healthcheck, an HTTP endpoint the app answers once it is ready. The healthcheck window is 120 seconds, so a new build that cannot answer within that time is never put into rotation and the old version keeps serving.
Once the new replicas are healthy, Railway shifts traffic to them and gives the old ones 60 seconds of draining. During draining the old containers stop receiving new requests and finish the ones already in flight, such as a chat response still streaming tokens. With two replicas, at least one healthy instance is always answering while the other is replaced or crashes, which removes the short outage a single replica would cause on every deploy.

| Setting | Value | Layer | What it protects |
|---|---|---|---|
| Replicas per API | 2 | [Railway] | Availability during deploys and crashes |
| Healthcheck timeout | 120 s | [Railway] | Broken builds never receive traffic |
| Draining | 60 s | [Railway] | In-flight requests finish cleanly |
| Clatri average CPU | 0.02 vCPU of 32 | [Railway] | Shows compute is far from the limit |
| Gurwi peak chats | 2 per minute | [Railway] | Shows load is far from the limit |
Worker architecture and distributed leasing
Each product splits its backend into two services. The API answers HTTP requests, and the worker runs background jobs such as processing an uploaded document. In both Gurwi and Clatri the worker is a separate Railway service with 1 replica, so a heavy job never competes with chat requests for the API's memory or event loop.
A single worker replica avoids a classic bug. If two workers poll the same jobs table at the same moment, both can read the same pending row and process it twice, which means double billing for model calls and duplicate chunks in the index. Running one worker makes that race impossible by design.
- jobs
- Supabase · Postgres
- id
- pending
- running
- done
- worker A
- worker B
- skipped
- lease renewed
- released
- status
- lease
- SELECT … FOR UPDATE SKIP LOCKED
- row locked
Leases protect the system even when that assumption breaks, for example during a deploy when the old and new worker overlap for a few seconds. A lease is a time-limited claim written to the database [Supabase]. The worker marks a job as taken by itself until a given timestamp, and other workers skip any job whose lease has not expired. Postgres makes the claim atomic with a row lock, typically through `FOR UPDATE SKIP LOCKED`, so two workers can never grab the same row in the same instant.
If the worker dies mid-job, it stops renewing the lease. Once the timestamp passes, the job becomes claimable again and the next worker picks it up. The database therefore acts as the referee, and the worker count becomes a performance decision rather than a correctness risk.
Gurwi document processing: throughput gains from concurrency
Gurwi's worker used to process documents one at a time. The setting `RESOURCEWORKERCONCURRENCY` was 1, so a second upload waited in the queue until the first finished. The value is now 3, letting the single worker replica handle three documents in parallel.
Supabase records from October 7 show 6 documents taking about 111 seconds each [Supabase]. With serial processing, the sixth document finishes after 6 × 111 = 666 seconds, a little over 11 minutes, even though its own processing took less than 2.

With concurrency 3, the six documents run in two waves. The first three finish at about 111 seconds and the next three at about 222 seconds, so the batch completes in under 4 minutes. Throughput rises from roughly 0.54 to 1.6 documents per minute.
- Total batch completion time
- s
- Average ready at
- Execution timeline of six documents
- Concurrency (worker slots)
- Concurrency
- Doc
- Slot
| Metric | Concurrency 1 | Concurrency 3 |
|---|---|---|
| Batch completion | 666 s | 222 s |
| Average time until a document is ready | 388.5 s | 166.5 s |
| Throughput | ~0.54 docs/min | ~1.6 docs/min |
The average wait matters most to the user. With serial processing the completion times are 111, 222, 333, 444, 555 and 666 seconds, which average 388.5. With three slots, three documents finish at 111 and three at 222, averaging 166.5. That math assumes each document still takes 111 seconds when three run at once. If they share a slow external API or saturate the worker's CPU, per-document time grows, so the October 7 durations should be compared against post-change measurements before treating the gain as final.


Pinpointing bottlenecks: Clatri database load versus Gurwi ingestion queues
Clatri and Gurwi strain in different places, and the telemetry makes that visible. Clatri has more live conversation. It recorded 4561 messages in 30 days and a peak of 6 users active in the same minute. Gurwi peaks at 2 chats per minute, and its pressure came from documents waiting in the worker queue.
For Clatri, the API is idle and the database is not. [Railway] shows 0.02 vCPU of 32 on average, while [Supabase] shows 74% RAM used and 53 of 90 connections open on the small compute tier. Postgres uses RAM for its cache and for each connection's working memory, so rising RAM and connection counts tend to move together.
Connection pool saturation looks different from CPU saturation. When the 90 connections fill, new queries wait for a free slot or fail with a "too many connections" error, even if the database CPU is calm. Adding API replicas makes this worse, since each replica opens its own pool. The fixes live in Supabase, through a transaction-mode pooler, smaller per-replica pools or a larger compute tier.
- Postgres connection slots
- Idle pool
- Active query
- Free
- CPU
- FATAL: remaining connection slots are reserved for non-replication superuser connections
For Gurwi, the symptom was latency for the user who uploads a document while others are already in line. Chat requests were fine, and the database had headroom. The fix was the worker concurrency change from the previous page, which touched the queue, not the API.
| Signal | Clatri | Gurwi |
|---|---|---|
| API CPU [Railway] | 0.02 vCPU of 32 | Low, 2 chats/min peak |
| Database RAM [Supabase] | 74% on small compute | Not the constraint |
| Connections [Supabase] | 53 of 90 | Not the constraint |
| Likely first bottleneck | Supabase memory and connections | Document queue in the worker |
Operational troubleshooting runbook: Railway CLI and Supabase telemetry
Each layer has its own instruments, and reading the wrong one wastes time. The Railway CLI shows what the containers are doing, and the Supabase dashboard shows what Postgres is doing. A quick pass through both usually places the problem in under five minutes.
Railway layer
These commands run from a terminal linked to the project. `railway link` selects the project and environment, `railway status` shows which service is active, `railway logs` streams that service's output, and `railway variables` lists settings such as `RESOURCEWORKERCONCURRENCY`.
railway link
railway status
railway logs
railway variablesIn the Railway dashboard, each service's Metrics tab shows CPU, memory and network per replica, and the Deployments tab shows whether a healthcheck failed. Run these against the API when requests fail, and against the worker when jobs stall.
Supabase layer
In the Supabase dashboard, the Reports section shows memory, CPU and connection counts for the database, which is where the 74% RAM and 53 of 90 connections came from. Query Performance lists the slowest and most frequent statements. For a live view, a query on `pgstatactivity` shows every open connection and its state.
psql "$SUPABASE_DB_URL" -c "select state, count(*) from pg_stat_activity group by state;"Many connections sitting in `idle` usually mean application pools holding slots they do not use. Many in `active` with long durations point to slow queries, which Query Performance can then name.

| Question | Tool | Layer |
|---|---|---|
| Is the container crashing? | `railway logs`, Deployments tab | [Railway] |
| Is CPU or memory high on a replica? | Metrics tab | [Railway] |
| What is the worker concurrency? | `railway variables` | [Railway] |
| Are connections near the limit? | Reports, `pgstatactivity` | [Supabase] |
| Which query is slow? | Query Performance | [Supabase] |
End-to-end architecture and incident diagnosis
Picture a Tuesday afternoon. Clatri users report that some messages fail with a server error, and the problem started shortly after a marketing email went out. Cloudflare shows normal traffic patterns, and the TLS certificate is valid.
The first check goes to [Railway]. Both API replicas are healthy, CPU sits well under one vCPU, and `railway logs` shows errors mentioning that the database rejected new connections. The containers are fine, and they are reporting a problem one hop further down.
The second check goes to [Supabase]. Reports show connections pinned at 90 of 90 and RAM above its usual 74%. `pgstatactivity` reveals dozens of idle connections held by API pools. The diagnosis is connection pool saturation in the database, and the response is to route the API through the transaction pooler, shrink per-replica pool sizes or move up from small compute. Adding Railway replicas would open more connections and deepen the outage.
A Gurwi incident on the same day would look different. Users say their uploaded PDFs stay in processing for many minutes while chat works normally. The API metrics are calm, Supabase has headroom, and the jobs table shows a growing number of pending rows. The constraint is the worker queue, and the levers are `RESOURCEWORKERCONCURRENCY` and the per-document processing time.
Across both products, the architecture keeps the same shape. Cloudflare fronts the domain, Railway routes among 2 API replicas and runs 1 worker per product, and Supabase holds the data, the leases and the connection limit. Most incidents today will surface in the database for Clatri and in the queue for Gurwi, long before the API needs more compute.

- Reset view
- 3D diagram: user, Cloudflare, Railway router, two API replicas, worker and Supabase Postgres
- User device
- Cloudflare
- TLS
- Railway router
- API replica ×2
- worker ×1
- Supabase Postgres
- Connections
- Request
- Response
- Rejected
When Supabase read replicas make sense for Clatri and Gurwi
A read replica in Supabase is a second Postgres instance with its own compute, its own copy of the data and its own connection limits, both direct and through the pooler. It serves only reads. Every insert, update and lease claim still goes to the primary, so Gurwi's worker would keep writing chunks and renewing leases on the same database it uses today.
Supabase sets several conditions for adding one. The project needs a Pro, Team or Enterprise plan, has to run on AWS with Postgres 15 or later, and needs at least a Small compute add-on. The replica inherits the primary's compute size. Projects below XL can have up to 2 replicas, and projects on XL or above can have up to 5. Supabase bills each replica's compute, disk, IOPS and throughput separately, so every replica adds a second line to the invoice.
Why a replica leaves Clatri's problem in place
Clatri's limit is the number of connections the primary accepts, the 90 slots that filled up during the Tuesday incident. A replica does nothing for those slots unless the app actually sends read traffic to it, and even then the replica brings its own connection ceiling that API pools can fill in the same way. Adding one while the pools stay oversized gives Clatri two instances to manage and pay for, each able to run out of connections. The fix for connections stays the same as before, with the transaction-mode pooler, smaller pools per replica or a larger compute tier.
The order of the levers
The first step is to optimize the slow queries and confirm the cost with `EXPLAIN ANALYZE`, which runs a query and reports where Postgres spends its time. If reads make up less than 80% of the workload, a larger compute tier helps more than a replica. If the workload is heavily read-oriented and the primary is already close to the top of vertical scaling (16XL), replicas become the next step.
Replicas can make sense earlier for two reasons. One is isolating heavy work, such as reporting or analytics, so a long report never slows the queries that serve live chats. The other is serving reads from a region closer to users, which cuts latency.
How each product would grow
If Clatri's traffic grows, the natural design sends heavy reads, such as message history and reports, to a replica and keeps every write on the primary, with the pooler in front of both. Gurwi would follow a different path. Its worker queue stays the first place to watch, and replicas would only arrive once the volume of reads justifies the extra instance.
- Clatri
- Gurwi
- Architecture diagram
- Active users
- Read share of traffic
- Worker concurrency
- API replicas on Railway
- Supabase compute
- Small
- Medium
- Large
- XL
- Write
- Read
- Queue
- Messages per user, 10 min
- Documents per user, 10 min
- Text
- Images
- Other
- Documents
- As it is now
- jobs
- docs
- Users
- {0} active
- Cloudflare
- {0} req/min
- Railway
- router
- API ×{0}
- {0} / 32 vCPU
- {0} req/min each
- {0} × 22 = {1} conns
- Worker
- {0} tasks in parallel
- {0} {1}/min in
- {0}/min done at most
- queue: {0}
- Supavisor
- Transaction mode
- {0} tx/s
- Postgres
- primary, {0}
- conns {0}/{1}
- RAM {0}%
- CPU {0}%
- Read replica
- reads only
- Postgres primary
- Worker queue
- API replicas
- First to break: Postgres primary, with {0} of {1} connections and {2} of them from API pools. Next lever: raise the Supabase compute.
- First to break: Postgres primary, with CPU or RAM at {0}%. Next lever: raise the Supabase compute.
- First to break: read replica, with {0} of {1} connections and one pool per API replica. Next lever: fewer API replicas or a larger compute.
- First to break: worker queue, with {0} {2}/min arriving and {1} done. Next lever: more worker concurrency.
- First to break: API replicas at {0}% of capacity. Next lever: add a replica, and watch the extra pool of connections it opens.
- First to break: Supavisor at {0}% of its throughput. Next lever: raise the Supabase compute.
- Closest to its limit: {0} at {1}%. Nothing is broken yet.
- Everything has headroom. Busiest: {0} at {1}%.
| Observed signal | Right lever |
|---|---|
| Connections exhausted while CPU stays calm | Pooler in transaction mode and smaller pools |
| High RAM and CPU with a mostly write workload | Larger compute tier |
| Reads above 80% of traffic | Read replica |
| Need to isolate reporting or serve another region | Read replica, before reaching the vertical ceiling |
- Observed signal
- Connections exhausted
- CPU calm
- Transaction pooler + smaller pools
- High RAM and CPU
- Mostly writes
- Larger compute tier
- Reads above 80%
- of traffic
- Read replica
- Isolate reporting
- or serve another region
- Read replica before the ceiling
- Pooler
- Compute
- Replica
- Next
Frequently asked questions
What is the difference between vertical and horizontal scaling?
Vertical scaling gives one instance more CPU and RAM, while horizontal scaling runs more identical instances side by side. Both Gurwi and Clatri run their API horizontally with 2 replicas in Railway, even though compute demand did not force that choice.
Why does Clatri run two API replicas if one container is enough?
Clatri's API averages only 0.02 vCPU out of 32 available, so a second replica adds almost nothing in raw capacity. The second replica pays off during deploys, because at least one healthy instance always answers while the other is replaced or crashes.
What happens if a new build fails its healthcheck during a deploy?
Railway only routes traffic to replicas that pass the healthcheck within the 120-second window. If the new build never becomes healthy, the deploy fails and the existing replicas stay in rotation, so users keep being served by the old version.
Why do Gurwi and Clatri run only one worker replica?
If two workers poll the same jobs table at the same moment, both can read the same pending row and process it twice, causing double billing for model calls and duplicate chunks in the index. Running one worker makes that race impossible by design.
How does raising RESOURCE_WORKER_CONCURRENCY from 1 to 3 change Gurwi document processing?
With concurrency 1, six documents at about 111 seconds each finish in 666 seconds, roughly 0.54 docs/min. With concurrency 3 they run in two waves and finish in 222 seconds, raising throughput to about 1.6 docs/min.
