Custom Middleware Design: Performance and Side Effects

Custom Middleware Design: Performance and Side Effects

Build your own interceptors. Learn how to write custom middleware for logging, rate limiting, and request tracking without killing performance.

Custom Middleware Design: Performance and Side Effects

Standard middleware handles the basics, but most non-trivial apps need custom logic that runs globally. Whether it's adding a X-Request-ID for debugging or implementing a primitive rate-limiter, you need to know how to design middleware that is Safe and Fast.

In this lesson, we learn the art of the custom interceptor.


1. The Anatomy of Custom Middleware

We use the @app.middleware("http") decorator. Your function receives the request and a call_next function.

@app.middleware("http")
async def log_requests(request: Request, call_next):
    # Potential Side Effect: Blocking here stops the WHOLE app
    client_ip = request.client.host
    print(f"Incoming request from {client_ip}")

    response = await call_next(request)
    
    return response

2. Performance Considerations: Don't Block!

Middleware runs for every single request. If your middleware does something slow (like a database query or a slow API call), you will add that latency to every single endpoint in your app.

The Problem:

If you do a 100ms database lookup in your middleware, an endpoint that normally takes 5ms now takes 105ms. You have just slowed down your entire API by 2,000%.

The Solution:

Keep middleware logic as "Lightweight" as possible. Use it for:

  • Reading Headers.
  • Generating Request IDs.
  • Simple mathematical checks (Rate Limit counters in Redis).
  • Basic logging.

3. Modifying the Response

One of the most powerful uses of custom middleware is injecting data into the response before it leaves.

import uuid

@app.middleware("http")
async def add_custom_headers(request: Request, call_next):
    request_id = str(uuid.uuid4())
    # Attach to request for use in code
    request.state.request_id = request_id
    
    response = await call_next(request)
    
    # Attach to response for use by client
    response.headers["X-Request-ID"] = request_id
    return response

4. Handling Exceptions in Middleware

Middleware is a double-edged sword. If an unhandled error happens inside your middleware, the user gets a generic "Internal Server Error," and your path operation might never even run.

Always wrap complex middleware logic in a try/except block to ensure the request can at least attempt to finish.


Visualizing Middleware Performance

graph TD
    A["Request arrives"] --> B["Middleware: Extract Headers (1ms)"]
    B --> C["Middleware: Block with DB Check (100ms)"]
    C --> D["FastAPI Route Logic (10ms)"]
    D --> E["Middleware: Add Timing Header (1ms)"]
    E --> F["Total Latency: 112ms"]
    
    style C fill:#f96,stroke:#333Internal Delay

Summary

  • Precision: Only use middleware for tasks that truly need to be global.
  • Performance: Avoid heavy I/O (like DB calls) inside middleware whenever possible.
  • request.state: Use this to pass information from the middleware down to your route functions.
  • Safety: Ensure your middleware is robust and doesn't crash the whole pipeline.

In the next lesson, we wrap up Module 8 with Exercises on middleware and the request cycle.


Exercise: The Latency Tracker

Write a custom middleware that tracks how many seconds it takes for a request to reach the database. Use request.state to store the start time. Hint: You'll need to use the time module.

Subscribe to our newsletter

Get the latest posts delivered right to your inbox.

Subscribe on LinkedIn