Fetching latest headlines…

Dev

Your App Works With 10 Users. Here’s What Breaks at 1,000.

Dev.toUnited States · NORTH AMERICA

Your app feels fast. The dashboard loads instantly. Search works. Creating an order takes a second. Then more people start using it. Suddenly, requests time out, dashboards crawl, and two customers ma...

1 views0 likes0 comments

Your app feels fast.

The dashboard loads instantly. Search works. Creating an order takes a second.

Then more people start using it.

Suddenly, requests time out, dashboards crawl, and two customers manage to buy the last item.

You probably don’t need to rewrite everything as microservices.

Start by looking for small assumptions that growth has exposed.

“1,000 users” is not a technical threshold. Ten users running expensive reports can create more load than a thousand browsing static pages. Data size, concurrency, and workload matter.

Here are five common problems worth checking before your app grows.

1. One page quietly runs hundreds of database queries

Imagine a dashboard that displays orders and their customers.

This code looks straightforward:

const orders = await db.getRecentOrders();

for (const order of orders) {
  order.customer = await db.getCustomer(order.customerId);
}

If the database returns 100 orders, you run:

  • One query to fetch the orders.
  • Another 100 queries to fetch their customers.

That is the N+1 query problem.

With a tiny dataset and a nearby database, you might barely notice it. With more records and network latency, those sequential queries become expensive.

The simplest fix

Fetch related data together using a join or your ORM’s relationship-loading feature.

Alternatively, fetch customers in one batch:

const orders = await db.getRecentOrders();

const customerIds = [
  ...new Set(orders.map((order) => order.customerId)),
];

const customers = await db.getCustomersByIds(customerIds);

const customersById = new Map(
  customers.map((customer) => [customer.id, customer])
);

const result = orders.map((order) => ({
  ...order,
  customer: customersById.get(order.customerId),
}));

The database helpers here are illustrative; adapt them to your stack.

What to check: How many database queries does one request execute?

Also, replacing the loop with Promise.all() does not remove the extra queries. It can simply send them to the database simultaneously.

2. “Fetch everything” stops being convenient

Your first customer has 20 invoices.

Returning all of them works fine:

SELECT *
FROM invoices
WHERE account_id = $1;

Later, an account has 50,000 invoices.

Now your database retrieves them, your server serializes them, the network transfers them, and the browser tries to handle them.

Even if the interface displays only ten rows, you have already paid for the entire response.

The simplest fix

Add pagination and return only the fields the screen needs:

SELECT id, total, status, created_at
FROM invoices
WHERE account_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 50;

For subsequent pages, a cursor can identify where to continue:

SELECT id, total, status, created_at
FROM invoices
WHERE account_id = $1
  AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 50;

This PostgreSQL example uses the last row’s timestamp and ID as the next cursor. The ID breaks ties when timestamps match.

Offset pagination is often adequate for small lists. Cursor pagination can help with large, changing datasets, but it introduces its own navigation tradeoffs.

What to check: Does any endpoint return an unlimited number of records?

3. Two requests can both pass the same check

Suppose your shop has one item left.

Your purchase logic does this:

const product = await db.getProduct(productId);

if (product.stock > 0) {
  await db.decreaseStock(productId);
  await db.createOrder(productId);
}

One request at a time? Looks fine.

Two requests at once?

Both can read stock = 1. Both pass the check. Both proceed.

The check was correct when each request read it. The decision was not protected from concurrent changes.

The simplest fix

For this stock operation, make the condition and update atomic:

UPDATE products
SET stock = stock - 1
WHERE id = $1
  AND stock > 0
RETURNING id, stock;

If no row is returned, the product was unavailable—or did not exist.

If order creation must succeed together with the stock update, put both database changes in one transaction. Roll back if either fails.

This solves concurrent stock claims. It does not automatically prevent the same purchase request from being processed twice; that needs separate duplicate-operation protection.

What to check: Where does your code read a value, make a decision, and update it afterward?

Balances, booking slots, usage limits, and inventory deserve particular attention.

4. Your request does too much before responding

A user creates a project.

Your server:

  1. Saves the project.
  2. Sends a welcome email.
  3. Generates a PDF.
  4. Calls an external service.
  5. Returns success.

A slow email provider now makes project creation feel slow—even though the project was saved already.

If the request times out, the user may retry and create a duplicate.

The simplest fix

Keep the essential work in the request. Move suitable secondary work to a durable background queue:

const project = await db.createProject(input);

await jobs.enqueue("send-project-email", {
  projectId: project.id,
});

return project;

But notice the gap: what happens if the database write succeeds and enqueueing fails?

For work that must not be lost, a transactional outbox is one option. Save the project and a pending job record in the same database transaction. A worker then delivers the job to the queue.

Also assume jobs may run more than once. Make their effects safe to repeat where possible.

Avoid relying on an unawaited promise for important work. A process restart can interrupt it.

What to check: Which steps must finish before you can honestly tell the user the action succeeded?

5. Your database scans more data than you expect

This query might feel instant with a few hundred rows:

SELECT id, total, created_at
FROM orders
WHERE account_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 50;

As the table grows, the database may need to examine and sort much more data.

The simplest fix

Inspect the query plan. For this access pattern, a candidate PostgreSQL index is:

CREATE INDEX orders_account_created_id_idx
ON orders (account_id, created_at DESC, id DESC);

It matches the account filter and ordering.

But don’t add indexes blindly.

Indexes use storage and add work to inserts, updates, and deletes. Their value depends on your actual queries and data.

Use representative data and inspect execution plans before deciding. Be aware that EXPLAIN ANALYZE executes the statement; take care with expensive queries and statements that modify data.

What to check: Which queries get slower as the table grows, and what do their plans show?

Measure before you rebuild

When an app slows down, “we need a new architecture” is an expensive first guess.

Start with a few measurements:

  • Request latency: Look at slower requests, including p95, rather than only averages.
  • Queries per request: Find endpoints doing unexpectedly many database calls.
  • Database time: Identify where requests spend time waiting.
  • Response size: Check whether the server sends more data than the screen needs.
  • Queue backlog: Make sure background work is keeping up.
  • Errors under concurrency: Test overlapping actions, not only sequential ones.

A useful load test reproduces realistic behavior: realistic records, realistic actions, and realistic concurrency.

Sending thousands of requests to a health-check endpoint tells you little about a dashboard that runs expensive queries.

A prompt for reviewing your code with AI

Review this endpoint for problems that could appear
with larger datasets or concurrent requests.

Do not suggest a rewrite yet.

Check for:
1. Database queries inside loops
2. Unbounded reads or oversized responses
3. Read-check-write race conditions
4. Slow external work inside the request
5. Queries that may need an index

For each finding:
- Point to the relevant code.
- Explain the failure mechanism.
- State assumptions and missing evidence.
- Suggest a measurement or test.
- Propose the smallest useful fix.

Do not invent performance numbers.

Treat the output as a review plan. Verify the findings with query counts, traces, execution plans, and tests.

Growth exposes assumptions

The early version of your app may assume:

  • There won’t be many records.
  • Requests won’t overlap.
  • External services will respond quickly.
  • Users won’t retry.
  • Every query will stay fast.

Those assumptions are easy to miss when the app is small.

Before changing your architecture, find the assumption that stopped being true.

What was the first thing that broke when your app started growing?

Comments (0)

Sign in to join the discussion

Be the first to comment!