# 7 Ways to Make Your API Faster

> Source: <https://dev.to/lovestaco/7-ways-to-make-your-api-faster-4020>
> Published: 2026-09-08 16:42:29+00:00

*Hello, I'm Maneshwar, and I'm building LiveReview — a blast-radius aware AI code review built for your business-critical systems. [Star us](https://github.com/HexmosTech/LiveReview/) to help devs discover the project, give it a try, and share your feedback to help improve the product.*

Your API is slow.

You know it is slow because somebody in the product channel posted a screenshot of a spinner with the caption "is this normal".

So you open the codebase, and within ninety seconds you have three theories, two refactors planned, and a strong urge to swap the JSON library.

Stop.

Put the keyboard down.

Optimization is not step one. Optimization is step four, after measurement, after confirmation, and after you have found something that is actually slow.

Every optimization in this post buys you speed with **complexity**. 

Cache invalidation, connection pool tuning, pagination cursors, async log buffers, none of that is free. You pay for it forever, in every future debugging session.

So the entry fee is a profile.

Load test the endpoint, look at where the time actually goes, and only then pick a technique from the list below.

The number of times I have watched somebody spend a week optimizing serialization for an endpoint whose real problem was one unindexed query is not a small number.

Measure. Confirm. Then optimize. In that order, every time.

Okay. Assume you measured. Here are the seven things worth reaching for.

Caching is the highest leverage trick on this list, because the fastest database query is the one you never send.

The shape is simple. An expensive computation runs once, the result goes into Redis or Memcached, and the next N callers asking the same question get the stored answer.

The catch is that people think caching is a big architectural commitment. It usually is not.

``` python
def get_top_products(category: str):
    key = f"top_products:{category}"
    if hit := redis.get(key):
        return json.loads(hit)

    result = db.query_expensive_top_products(category)
    redis.setex(key, 30, json.dumps(result))   # thirty seconds. that is it.
    return result
```

Look at that TTL. Thirty seconds.

That feels almost insultingly short, and it is exactly the point.

If an endpoint takes 400ms and gets hit 200 times a minute, a thirty second cache removes something like 99% of those database hits, and nobody downstream ever notices data that is half a minute stale.

Short TTLs are underrated because they give you most of the win with almost none of the invalidation pain.

You are not maintaining a cache, you are just refusing to answer the same question 200 times in a row.

Where it gets genuinely hard is when the data must be fresh, and then you are in invalidation territory, which is famously [one of the two hard things in computer science](https://martinfowler.com/bliki/TwoHardThings.html).

Start with the boring TTL version. Graduate to invalidation only when the TTL version is provably wrong for your use case.

Opening a database connection is not free.

There is a TCP handshake, usually a TLS handshake, then authentication, then session setup.

You can easily spend more time saying hello to Postgres than you spend querying it.

Connection pooling keeps a set of connections open and hands them out. Your request borrows one, runs its query, and gives it back.

Most frameworks do this by default and you never think about it. Which is fine, right up until the day you go serverless.

Serverless breaks the assumption underneath pooling. Each function instance is its own little process with its own little pool, and the platform will happily spin up 500 of them during a traffic spike.

Now your database, which is configured for maybe 100 connections, is getting introduced to 500 strangers at once.

Postgres in particular does not degrade gracefully here. It forks a process per connection, so connection exhaustion is not a slowdown, it is a wall.

That is the entire reason [AWS RDS Proxy](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy.html) exists, along with pgbouncer and friends. 

They sit between your ephemeral functions and your very much non-ephemeral database, and multiplex a small pool of real connections across a large number of callers.

```
flowchart LR
    subgraph Serverless
      F1[Fn instance 1]
      F2[Fn instance 2]
      F3[Fn instance ...500]
    end

    F1 --> P[Connection Proxy]
    F2 --> P
    F3 --> P
    P -->|small, reused pool| DB[(Postgres)]

    F1 -.->|without a proxy| DB
    F2 -.->|500 handshakes| DB
    F3 -.->|database says no| DB

    classDef fn fill:#6ea8ff,stroke:#2f5fb8,color:#1a1a1a
    classDef proxy fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
    classDef db fill:#ff9a5c,stroke:#c0632c,color:#1a1a1a

    class F1,F2,F3 fn
    class P proxy
    class DB db
```

This is my favourite one, because the N+1 query is almost always the result of writing *nicer* code.

You have posts. Each post has comments. So you write the obvious thing:

```
posts = db.query("SELECT * FROM posts LIMIT 20")

for post in posts:
    # one extra round trip. every. single. time.
    post.comments = db.query("SELECT * FROM comments WHERE post_id = %s", post.id)
```

That is 21 queries to render one page. Twenty of them are identical in shape and differ only in an integer.

On your laptop the database is a localhost away, each query costs 0.2ms, and the whole loop finishes before you can blink.

In production the database is across a network, each round trip costs 3ms, and you have just spent 60ms doing nothing but waiting.

Scale that page to 200 posts and you have an endpoint that is somehow slow without a single slow query in the logs.

That is what makes N+1 so nasty. Every individual query looks fine. The slow query log has nothing to say. Only the count is wrong.

The fix is to stop asking one at a time:

```
posts = db.query("SELECT * FROM posts LIMIT 20")
ids   = [p.id for p in posts]

rows = db.query("SELECT * FROM comments WHERE post_id = ANY(%s)", ids)

by_post = defaultdict(list)
for row in rows:
    by_post[row.post_id].append(row)

for post in posts:
    post.comments = by_post[post.id]
```

Two queries. Constant, regardless of how many posts you fetch.

If you are on an ORM, this is what `select_related` and `prefetch_related` in Django, `joinedload` in SQLAlchemy, and `include` in Prisma exist for. 

The tooling is there, it is just off by default, because the ORM cannot know whether you wanted the related rows.

The single most useful habit here is to log your query count per request in development. An endpoint that fires 47 queries will tell on itself immediately.

Somewhere in every codebase there is an endpoint that started life returning 12 records and now returns 40,000, because the table grew and nobody revisited the handler.

The database has to fetch it. Your app has to serialize it. The network has to ship it.

The client has to parse it, and then render precisely the first twenty of them.

Pagination is the fix and everyone knows it. What everyone does not know is that `LIMIT 20 OFFSET 100000` is not actually fast.

`OFFSET` does not skip work. The database still walks all 100,000 rows and throws them away before handing you twenty. 

Deep pages get linearly slower, and your "optimization" quietly becomes the new bottleneck.

Cursor based pagination avoids this by asking the question differently. Instead of "give me page 5000", you ask "give me the twenty rows after this one":

```
-- offset:  gets slower the deeper you go
SELECT * FROM events ORDER BY id LIMIT 20 OFFSET 100000;

-- cursor:  uses the index, same cost on page 1 and page 5000
SELECT * FROM events WHERE id > 100000 ORDER BY id LIMIT 20;
```

The second one is an index seek. It costs the same at any depth.

The tradeoff is that you lose random access to page numbers, which is why offset pagination survives in admin panels and cursor pagination is what you find in [Stripe's API](https://docs.stripe.com/api/pagination) and every infinite scroll feed you have ever used.

Once the data is in memory, something has to turn it into JSON, and that something is running on your CPU for every single response.

For small payloads this is noise. For an endpoint returning a few thousand objects, serialization can genuinely become the dominant cost, and the profiler will point right at it.

The good news is that this is the cheapest fix on the entire list, because it is usually a library swap. In Python, `orjson` is meaningfully faster than the standard library `json`. 

In Node, the JSON serializer is native but schema based approaches like [fast-json-stringify](https://github.com/fastify/fast-json-stringify) beat it by knowing the shape in advance. Serializers that get told the schema upfront can skip all the runtime type sniffing.

But please, actually profile first. Swapping serializers on an endpoint that spends 95% of its time in the database is a lovely way to spend an afternoon achieving nothing.

JSON compresses beautifully, because JSON is mostly repeated key names and whitespace. Compression ratios of 5x to 10x on API responses are completely normal.

That is 5x to 10x less data crossing the network, which matters enormously for anyone on mobile, and matters for everyone once payloads get big.

gzip is the safe default that every client on earth supports. [Brotli](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Encoding) generally compresses smaller at comparable speed and is supported by every modern browser, so it is worth enabling where you can.

Two things to keep in mind.

Compression costs CPU. You are trading processor time for network time, which is nearly always a good trade, but it is still a trade.

Small responses can genuinely come out slower once you add the compression overhead, so most servers have a minimum size threshold, and you should leave it on.

And you very likely should not be doing this yourself. Cloudflare, Fastly and friends will compress at the edge for you, which moves the CPU cost off your servers entirely and applies it uniformly to everything you serve.

If you are already behind a CDN, this optimization is a checkbox.

This one is last for a reason. Most services should not care.

But in a high throughput path, writing a log line is a syscall, and if that write is synchronous and blocking, your request thread is sitting there waiting on a disk or a network socket while doing nothing useful.

Async logging fixes this by making the request thread's job trivial. It drops the log entry into an in memory ring buffer and moves on immediately. A separate thread drains the buffer and does the actual writing.

The request path goes from "wait for the write" to "append to a queue".

``` php
flowchart LR
    R[Request thread] -->|append, microseconds| B[In-memory buffer]
    B --> W[Logger thread]
    W --> D[(Disk / log service)]

    R --> RESP[Response sent]

    C{App crashes<br/>before flush?}
    B -.-> C
    C -->|yes| L[Buffered logs lost]
    C -->|no| D

    classDef thread fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
    classDef buf fill:#6ea8ff,stroke:#2f5fb8,color:#1a1a1a
    classDef decision fill:#f4d35e,stroke:#b8991f,color:#1a1a1a
    classDef bad fill:#ff9a5c,stroke:#c0632c,color:#1a1a1a

    class R,W thread
    class B,RESP buf
    class C decision
    class L,D bad
```

That dotted branch is the whole tradeoff, and you should stare at it before enabling this.

Anything sitting in the buffer when the process dies is gone. Which means the logs describing the crash are exactly the logs most likely to be lost.

That is a genuinely bad trade for audit logs, payment records, or anything you would need in an incident review.

It is a perfectly fine trade for high volume access logs where losing the last few hundred lines costs you nothing.

Pick per log stream, not per application.

Look at the seven again and notice how they cluster.

Caching, pooling and N+1 are all about **not talking to the database**, whether by skipping the question, skipping the handshake, or asking once instead of twenty times.

Pagination, serialization and compression are all about **moving less data**, at the query, at the CPU, and on the wire.

Async logging is about **getting out of the request path**, which is the same idea as background jobs, applied to something small.

None of them are exotic. All of them are boring, well understood, and sitting one library call away.

The hard part was never knowing the techniques. The hard part is having the discipline to find out which one your endpoint actually needs, instead of applying all seven and calling it architecture.

Profile first. Fix the thing the profile points at. Then go do something more interesting.

Your team's attention is limited, and the deluge of AI-generated code is making it harder to keep production secure and reliable without slowing you down.

I'm building **LiveReview**, a blast-radius aware AI code review built for your business-critical systems.

Instead of presenting every diff with equal emphasis, **LiveReview scores each change by blast radius — how far its impact reaches through your call graph — so you can focus attention where it actually matters.**

Spend code review effort where business risk is highest — not spread evenly across every diff.

⭐ Star it on GitHub:

LiveReview is an AI code reviewer that scores every hunk of a diff by **blast radius**: how far a change reaches through your call graph, how much persistent state it touches, and how well-tested it is. A 3-line change to a shared auth check can outrank a 300-line UI tweak. Your team's attention goes to the highest-risk code first, not spread evenly across every diff.

*LiveReview's Blast Radius & Review Priority scoring, live in the diff viewer.*

| The exact math, not a black box | Visualize blast radius at a glance | Every factor that feeds the score | 
|---|---|---|

**Here's the goal:**

**Click below to try LiveReview with your codebase:**
