Success story · Fintech-adjacent · Enterprise pilot

The page that took fifteen seconds: passing an enterprise pilot with a Cursor-built app.

A leasing-broker tool built with Cursor won a pilot with a bank's partner network. The pilot brought 50,000 records. The dashboard went from 90 milliseconds to 15.8 seconds and memory quadrupled. The pilot manager gave them two weeks. We used two days.

Leasing / brokerage, enterprise pilotBuilt with CursorCTO-founderReadiness 2.3 → 3.848 hours
Success story · Fintech-adjacent · Enterprise pilot
15.8 sp95 before at 50k rows→9,171requests in the gate run, 0 errors
The page that took fifteen seconds: passing an enterprise pilot with a Cursor-built app.
prodreadyBuilt with Cursor · production-ready in 48 hours
15.8 s
p95 before at 50k rows
2.5
requests / s before
25 ms
p95 after, same data
9,171
requests in the gate run, 0 errors

Sound familiar?

You built the app in Cursor. It works. Your own customers use it every day and nobody complains about speed, because nobody has more than a few hundred records. Then a bigger customer says yes to a pilot, and their data is a hundred times larger than anything you have loaded.

The first demo goes fine because you show them your data. The second demo is on theirs. The list page spins. You say "we'll add caching".

You are now the CTO of a company whose main page takes fifteen seconds, and the person across the table has a deadline and two other vendors.

What they sent us

A leasing-broker tool: dealers submit vehicle lease applications, brokers price them, the lender's back office approves. Built in Cursor by a two-person team, in production for their own brokerage for about a year. Then it won a pilot with a bank's dealer partner network. The pilot brought 50,000 applications in one import, and the pilot manager gave the founder two weeks to prove the tool could carry them.

Discovery scanned 19 dimensions and scored the app 2.3 out of 5, "developing". The security and data findings were the usual ones. The finding that mattered for the pilot was scale.

On the founder's own data the applications list answered in 91.5 ms at the 95th percentile and the server took 205 requests a second. With the pilot's 50,000 rows loaded, the same endpoint took 15.8 s at p95 and the server managed 2.55 requests a second. Resident memory went from 220 MB to 842 MB and stayed there.

The cause was two endpoints and one process. The list endpoint ran SELECT * over every application the tenant owned, loaded all of them into Python objects, and sliced the page out of the list. The CSV export did the same, then joined the rows into one string before sending a byte. Both ran on a single synchronous worker, so one export blocked every other user for as long as it took, and two exports blocked the server for twice as long.

There was no load test, no CI, and no number anyone could put in front of the pilot manager. The founder had a hunch that the page was slow on big data. The hunch was correct, and it was worth nothing in a procurement meeting.

Browser Cursor-built front end One sync worker one request at a time memory 220 MB, then 842 MB no queue, no worker process Postgres 50,000 applications GET /applications SELECT * loads all 50,000 rows into RAM page sliced in Python: p95 15.8 s GET /export whole CSV built as one string in memory blocks every other user until it finishes BEFORE: 2.55 requests per second at 50,000 rows
Before: one synchronous worker, a list endpoint that loads all 50,000 rows to show 50, and an export that builds the whole file in memory. One slow request stalls everyone.

What would have happened

What actually happens

Day 3 of the pilot: the second demo, to the dealer network's operations lead, on their data. The applications page takes fifteen seconds. Someone on the call starts an export; the page for everyone else stops answering until it finishes. The founder says "we'll add caching".

Day 5: the pilot manager replies with one line, "please send your load-test report". There is none. Day 12: procurement asks the same question in writing, because the security questionnaire has a performance section and the answer field is empty.

Day 14: the two weeks are up. The pilot slot goes to the incumbent vendor with the worse product and the completed questionnaire. Pilots are lost on the second slow demo, not the first, and this bank's partner network does not run a second pilot with the same vendor for a long time.

Forty-eight hours

Hour 0–6. First we made the problem reproducible, because a slow page you cannot reproduce on demand is a rumour. A seed script generated 50,000 applications across the pilot's dealers, and a first k6 script confirmed the Discovery numbers within a few percent.

Then we read the list endpoint. It had no LIMIT, no index on the sort column, and a pagination query parameter that the code accepted and ignored.

We replaced it with keyset pagination. Instead of an offset, the client sends the last row it saw, and the database seeks straight to the next 50 rows using a composite index. The cost of page 900 is the same as page 1.

-- first page
SELECT id, created_at, dealer_id, status, amount
FROM applications WHERE tenant_id = $1
ORDER BY created_at DESC, id DESC LIMIT 50;

-- next page: the client sends the last row's (created_at, id)
SELECT id, created_at, dealer_id, status, amount
FROM applications WHERE tenant_id = $1
  AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC LIMIT 50;

-- CREATE INDEX ... ON applications (tenant_id, created_at DESC, id DESC);

The row tuple comparison is the whole trick: Postgres compares (created_at, id) as one value, so ties on the timestamp still page correctly, and the index covers the seek. The API returns the cursor as an opaque string. A page size above 200, or an unknown query parameter, returns 422 before any query runs; our reviewers reject endpoints that validate after the database has already done the work.

Hour 6–18. The export. Building a 50,000-row CSV as one string is what pushed memory to 842 MB, and doing it on the only worker is what made everyone else wait. We rewrote it as a streamed response over a server-side cursor that fetches 2,000 rows at a time, so memory stays flat regardless of the file size.

The first version passed our tests and was rejected in review: nothing stopped ten users from starting ten exports at once. The second version holds a small semaphore and answers 429 with a Retry-After header when the slots are full.

EXPORT_SLOTS = threading.BoundedSemaphore(2)   # per web process

def export_csv(tenant_id):
    if not EXPORT_SLOTS.acquire(blocking=False):
        return Response(status=429, headers={"Retry-After": "30"})
    def rows():
        try:
            yield "id,created_at,dealer,status,amount\n"
            for r in iter_applications(tenant_id, batch=2000):  # server-side cursor
                yield f"{r.id},{r.created_at},{r.dealer},{r.status},{r.amount}\n"
        finally: EXPORT_SLOTS.release()
    return Response(rows(), mimetype="text/csv")

The slot is released in the generator's finally, so a client that disconnects halfway gives its slot back. A third concurrent export gets a 429 in under a millisecond instead of a fifteen-second wait, and the front end shows "export busy, retrying in 30 s". The same hour, the app moved from one worker to two web workers behind the reverse proxy, which is the number the host's memory allowed with headroom.

Hour 18–36. Everything that had no business inside a web request left it: the bulk import from the lender's file, the nightly dealer statements, PDF generation for approved applications. They became rows in a jobs table and a separate worker process claims them. The claim is one statement, and SKIP LOCKED is what lets a second worker run next to the first without both taking the same job.

UPDATE jobs SET state = 'running', started_at = now(), worker = $1
WHERE id = (
  SELECT id FROM jobs
  WHERE state = 'queued' AND run_after <= now()
  ORDER BY run_after, id
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING id, kind, payload;

No queue broker, no new infrastructure to run; the database the team already operates is the queue. The worker writes a heartbeat row every few seconds, and the /ready endpoint now checks the database, the migration head and that heartbeat. If the worker dies, the load balancer knows before a customer does. The first version scheduled the worker with cron inside the container; it failed silently as a non-root user, so the loop moved in-process where the readiness check can see it.

Hour 36–48. The gate. A fast page is an observation; a gate is a promise. We wrote the k6 scenario to match the pilot's shape: 50,000 seeded applications, 20 iterations a second for 300 seconds steady, between 20 and 50 virtual users, logins capped at 30 a minute the way the lockout rule would cap them in production.

A 30-second warm-up runs first and is excluded from the thresholds. Tokens come from k6's setup(), so no virtual user logs in on the hot path and the numbers measure the app, not the password hash.

thresholds: {
  "http_req_duration{gated:yes}": ["p(95)<500"],
  "http_req_failed{gated:yes}":   ["rate<0.01"],
  functional_failures:            ["count<1"],   // a 200 that did no work
},
scenarios: {
  warmup: { executor: "constant-arrival-rate", rate: 20, timeUnit: "1s",
            duration: "30s", preAllocatedVUs: 20, tags: { gated: "no" } },
  steady: { executor: "constant-arrival-rate", rate: 20, timeUnit: "1s",
            startTime: "30s", duration: "300s", preAllocatedVUs: 20, maxVUs: 50 },
}

Three thresholds. p95 under 500 ms, because that is the number in the bank's questionnaire. Errors under 1 %.

And zero functional failures, which is our own counter: each request checks that the response actually did the work, so a 200 with an empty page or a "try again later" body counts as a failure. The first gate went green on exit code alone; a reviewer rejected it for exactly that, and the raw k6 summary is now kept and hashed with every run.

The gate run that closed the 48 hours: 9,171 gated requests, p95 25.46 ms, p99 40 ms, 0 errors, 0 functional failures. It runs in CI on every merge and fails the build when it fails. Every prodready delivery ships with a gate like this, tuned to the customer's numbers. That summary, with the thresholds and the run hash, is the document the pilot manager asked for.

The pilot manager didn't want a faster page. He wanted a document that said it would still be fast next year. We got both.
48 hours of engineering Reproduce seed 50k, keyset Streamed export cursor, 2 slots, 429 Worker queue jobs table, SKIP LOCKED, heartbeat in /ready k6 gate in CI 300 s steady, 3 thresholds 6 h 12 h 18 h 12 h 0 h 6 h 18 h 36 h 48 h Before: p95 15.8 s, 2.55 req/s Web: 1 sync worker becomes 2 workers + 1 job worker After: p95 25.46 ms, 0 errors Discovery before hour 0. The pilot's own traffic after hour 48.
The 48 hours: reproduce and fix the list (0–6 h), stream and cap the export (6–18 h), move heavy work to a queue (18–36 h), then put the k6 gate in CI (36–48 h).

What it looks like now

The architecture is not exotic. Two web workers behind a TLS proxy, Postgres, and one job worker that claims from a table. What changed is that no request can take longer than the page it serves, no export can hold the server, and the whole thing is measured under the pilot's load on every merge.

Browser cursor-based paging TLS proxy auto-renewing certs Web: 2 workers /ready checks DB + worker heartbeat Postgres index (tenant, created_at, id) GET /applications keyset pages, 50 rows page 900 costs the same as page 1 GET /export streamed, 2,000 rows a batch cap 2 per process, then 429 Job worker FOR UPDATE SKIP LOCKED imports, statements, PDFs CI: k6 gate on every merge, 300 s at 20 iterations/s on 50,000 rows thresholds: p95 under 500 ms, errors under 1 %, functional failures = 0; the build fails otherwise last run: 9,171 requests, p95 25.46 ms, p99 40 ms, 0 errors; raw summary kept and hashed
After: keyset-paged list, streamed export capped at two per process with 429 beyond that, a job worker claiming with SKIP LOCKED, and a k6 gate in CI that fails the build on three thresholds.
Measured at 50,000 applicationsBeforeAfter
Applications list, p9515.8 s25.46 ms
Applications list, p99not measured40 ms
Throughput under load2.55 requests/s9,171 requests in 300 s, 0 errors
Web process memory220 MB, then 842 MBflat; export streams 2,000 rows at a time
Concurrent exportsunbounded, each blocks the server2 per process, then 429 with Retry-After
Long-running workinside web requestsjobs table, worker with SKIP LOCKED, heartbeat in /ready
Load testnonek6 gate in CI, 3 thresholds, summary hashed
Readiness score2.3 / 53.8 / 5

From our desk

The first k6 gate passed. It passed because k6 only fails on its own thresholds and every response was a 200. Some of those 200s were the login throttle politely telling a virtual user it was locked out, with a JSON body and no data.

The reviewer who caught it wrote one sentence: "a 200 that didn't do the work is a failure". The functional_failures counter exists because of that sentence, and it is the threshold the pilot manager asked the most questions about.


Do this tonight

Do this tonight

1. Load the customer's size, not yours. On a local copy, insert 50,000 rows into your biggest table with INSERT ... SELECT ... FROM generate_series(1, 50000), then time the list page: curl -s -o /dev/null -w "%{time_total} %{size_download}\n" -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/applications.

Over one second, or over one megabyte, is a page that loads everything.

2. Find the unbounded queries. Run grep -rnE "\.all\(\)|findMany\(|OFFSET|SELECT \*" src/. Every hit that serves a list endpoint without a LIMIT is a fifteen-second page waiting for a customer.

Ask Cursor to list every route that returns an array and whether it is paged; check its answer against the grep.

3. Start two exports at once. Open the export in two browser tabs, click both, and watch docker stats or top. If memory climbs and does not come back down, the file is being built in RAM.

If a third tab's normal page load stalls until the exports finish, you have one worker and no cap.

Those three checks tell you whether you have the problem. They do not give you the numbers under five minutes of sustained load, the gate that stops the next commit from putting the problem back, or the report an enterprise procurement team will accept in place of "we'll add caching".

The rule

The rule

A page that takes 90 ms on your data has not been tested. Test it at the size of the customer you want, then put that test where it can fail the build.

The pilot manager didn't want a faster page. He wanted a document that said it would still be fast next year. We got both.— CTO-founder

Send us what you have.

The readiness scan is free — install our MCP and your own coding agent runs it. When you want it fixed, send us what you have: fixed scope, fixed price, agreed before the clock starts. 48 hours later, it's production-ready — on your own cloud, with the tests to prove it.

Feed the machineWatch the 49-second film