My Food Delivery App Worked Great With 50 Orders, Then Client Got Real Customers

I built an order history page for a small food delivery startup, showing each customer their past orders, which restaurant it was from, the items ordered, and a quick rating option. During development and testing, I had maybe 50 sample orders in the database. Page loaded instantly. Looked great in the demo.

Three months after launch, the client had real traction, a few hundred active customers, thousands of orders piling up. Suddenly customers started complaining the order history page took “forever” to load, sometimes 8-10 seconds, sometimes timing out entirely on slower mobile connections.

I genuinely thought something had broken in a recent update at first. Checked recent commits, nothing suspicious. Then I actually looked at how many database queries were firing on that single page load using Laravel Debugbar, and I nearly fell out of my chair. Over 900 queries. For a page showing 30 orders.

That’s the N+1 query problem, and once you know what to look for, it’s honestly one of the easier performance issues to actually fix. Let me walk through exactly what happened and how I fixed it.

What N+1 Actually Means (Explained Simply)

The name sounds more technical than the actual concept. Here’s the simplest way I can put it: if you run 1 query to get a list of things, then run a separate additional query for each individual item in that list to get related data, you’ve got an N+1 problem. One query to get the list (“N” items), plus N additional queries, one for each item.

For my food delivery order page, it looked roughly like this:

// 1 query to get all orders
$orders = Order::where('customer_id', $customerId)->get();

foreach ($orders as $order) {
    echo $order->restaurant->name; // separate query per order
    echo $order->items->count(); // another separate query per order
    echo $order->rating->stars ?? 'Not rated'; // yet another separate query per order
}

If a customer had 30 orders in their history, that’s 1 query to fetch the orders, then 30 queries for restaurant names, 30 more for item counts, 30 more for ratings. That’s 91 queries for one customer’s order history page alone. Multiply that by every customer browsing their history simultaneously, and you can see exactly why the client’s server started struggling once real traffic hit.

How I Actually Found It (Don’t Skip This Step)

Before fixing anything, you need to actually confirm you have this problem and see exactly where. I use two tools for this depending on the project.

Laravel Debugbar — this is what I used for the food delivery app specifically, since it was already installed from earlier development. It shows a query count and full query list right at the bottom of every page during local development.

composer require barryvdh/laravel-debugbar --dev

Once installed, just load the page you’re worried about and check the “Queries” tab at the bottom of the browser. If you see a suspiciously high number, especially one that seems to scale with how much data is showing (like 30 orders somehow producing 90+ queries), that’s your clue.

Laravel Telescope — works similarly, and I’ve used this on projects where Debugbar wasn’t already set up. Its Queries tab shows the exact same information in a slightly different interface, including which file and line triggered each specific query, which is genuinely useful for pinpointing the exact spot in your code.

For the food delivery app, Debugbar showed me clearly, 91 queries just for the order history section alone, with the same query pattern (fetching a restaurant, fetching items, fetching a rating) repeating over and over with only the ID changing each time. That repetition pattern is the dead giveaway for N+1.

Step 1: Fixing It With Eager Loading

The fix is Eloquent’s with() method, which tells Laravel to grab all the related data upfront in a small number of additional queries, instead of one separate query per item.

// Before - the N+1 problem
$orders = Order::where('customer_id', $customerId)->get();

// After - eager loading
$orders = Order::where('customer_id', $customerId)
    ->with(['restaurant', 'items', 'rating'])
    ->get();

That single change took the query count from 91 down to just 4, one for the orders themselves, and one each for restaurants, items, and ratings, fetched all at once for every order in that result set instead of individually.

The Blade view or Livewire component itself didn’t need to change at all, since Laravel still lets you access $order->restaurant->name exactly the same way, it just already has that data loaded in memory instead of triggering a fresh query each time you access it.

Step 2: Nested Relationships (Where I Initially Missed a Spot)

The order history page also showed each order item’s specific menu item details, not just a count. My first attempt at fixing this still had a partial N+1 problem I didn’t catch right away:

$orders = Order::where('customer_id', $customerId)
    ->with(['restaurant', 'items', 'rating'])
    ->get();

foreach ($orders as $order) {
    foreach ($order->items as $item) {
        echo $item->menuItem->name; // still a separate query per item!
    }
}

I’d eager loaded items, but each item itself had its own relationship to a menuItem model that wasn’t eager loaded, so the N+1 problem just moved one level deeper instead of disappearing entirely. Fixed with nested eager loading using dot notation:

$orders = Order::where('customer_id', $customerId)
    ->with(['restaurant', 'items.menuItem', 'rating'])
    ->get();

This taught me a genuinely important lesson, checking your query count once and assuming you’re done isn’t enough if there are nested relationships involved. Always re-check the actual query count after applying eager loading, since a fix at one level can still leave a hidden N+1 problem one level deeper.

Step 3: Using withCount() Instead of Loading Full Relations Just to Count

On a different part of that same app, an admin dashboard showing how many orders each restaurant had received, I initially eager loaded the entire orders relationship just to count them:

$restaurants = Restaurant::with('orders')->get();

foreach ($restaurants as $restaurant) {
    echo $restaurant->orders->count();
}

This technically avoids the N+1 problem since it’s eager loaded, but it’s wasteful, you’re loading every single order’s full data into memory just to count how many there are. For a restaurant with thousands of orders, that’s a lot of unnecessary memory usage for something that just needs a number.

$restaurants = Restaurant::withCount('orders')->get();

foreach ($restaurants as $restaurant) {
    echo $restaurant->orders_count;
}

withCount() runs an efficient count query behind the scenes instead of loading full records, and gives you a clean orders_count attribute automatically. Much lighter for anything where you genuinely just need a number, not the actual related records themselves.

Step 4: Conditional Eager Loading (For When You Don’t Always Need Everything)

Not every page needs every relationship loaded. On the customer-facing order history page, I didn’t need the restaurant’s full menu or owner details, just the name and logo. Loading everything unconditionally, even with eager loading, is still wasteful if you’re pulling way more data than the page actually displays.

$orders = Order::where('customer_id', $customerId)
    ->with(['restaurant:id,name,logo', 'items.menuItem:id,name,price', 'rating'])
    ->get();

Specifying exact columns inside the with() array (just make sure to always include the actual foreign key and id columns needed for the relationship itself to work) reduces how much data gets pulled from the database, which matters more as your tables grow larger over time.

Real Before and After Numbers

For context, here’s what actually changed for the food delivery app’s order history page after all these fixes:

Before: 91+ queries for a single customer’s order history, page load around 4-8 seconds depending on order count, noticeably worse for customers with a longer order history.

After eager loading fixes: 4 queries total regardless of how many orders were shown, page load dropped to under 300 milliseconds consistently.

That’s not an exaggerated improvement for effect, it’s genuinely what happened, and it’s one of the more dramatic performance wins I’ve gotten from such a small code change on any project.

Common Mistakes I’d Warn You About

Fixing the top-level relationship but missing nested ones. As I mentioned, always re-check your query count after applying eager loading, since nested relationships can hide a second layer of the same problem.

Eager loading relationships you don’t actually need on that specific page. Loading everything “just in case” wastes memory and query time. Only eager load what that specific view or component actually uses.

Using with() for simple counts instead of withCount(). If you just need a number, withCount() is genuinely more efficient than loading full related records.

Not testing with realistic data volumes. This is exactly what got me on the food delivery project. Fifty test orders during development looked completely fine. Always test performance-sensitive pages with a realistic amount of seeded data, not just a handful of sample records, before assuming something’s fast enough for production.

Forgetting Livewire components can have this same problem. Since Livewire components re-render on every interaction, an N+1 problem inside a Livewire component’s render() method gets repeated on every single user interaction, not just once per page load, which can make the performance impact even worse than a traditional Blade page.

Where I’ve Caught This Same Pattern Since

Beyond the food delivery app, I’ve found and fixed the exact same N+1 pattern on:

  • A real estate listing page showing each property’s agent details and image counts
  • A blog’s homepage showing each post’s author name and category, which had genuinely been running well over 100 queries for a page showing just 15 posts
  • A gym membership admin panel showing each member’s trainer assignment and attendance count

Every single one of these had the exact same root cause, accessing a relationship inside a loop without eager loading it beforehand.

Final Thoughts

The scariest part about N+1 problems is how invisible they are during normal development. Everything genuinely works, the page loads, the data’s correct, nothing throws an error. It’s purely a performance problem that only becomes obvious once your actual data volume grows past whatever small sample set you were testing with.

If you’re building anything that loops through a list and accesses relationship data inside that loop, get in the habit of checking your query count with Debugbar or Telescope before considering it done, especially before deploying to production. That single habit would’ve saved me the slightly panicked client message asking why the app suddenly felt so slow.

Leave a Reply

Your email address will not be published. Required fields are marked *