# API Performance and Caching

Course: [API](https://www.devtutorials.ai/learn/building-apis) · Section: Production-Ready APIs
Source: https://www.devtutorials.ai/learn/building-apis/production-ready/performance-and-caching

> Make your API fast by not repeating work: fix N+1 queries, answer repeat visits with 304, and tell caches what they may keep.

**API performance** is about how quickly your API responds and how efficiently it uses resources. **Caching** is the practice of storing a copy of a response, so that it can be reused instead of being generated again. Together, performance optimization and caching help APIs handle more users, respond faster, and reduce unnecessary work.

A simple rule to remember is: **The fastest request is the one you do not have to process again.**

## Watch One Request Make Six Trips

This API returns five orders and their items. To measure how efficient it is, the example includes a small fake database that counts how many database queries are made during a request. The count appears in the response header `X-Db-Queries`. Press **Run**.

The response looks correct. You receive five orders and their related items. However, notice the header: `X-Db-Queries: 6`

How did that happen?

1. One query loaded all orders.
2. Five additional queries loaded items for each order.

The total is `1 + 5 = 6 queries`. With 100 orders, it would be `101 queries`. With 1,000 orders, it would be `1001 queries`. This is called an **N+1 query problem**.

**In simple words:** the API keeps repeating the same database operation instead of fetching everything efficiently.

## TL;DR

- **N+1 queries** happen when one query loads a list and another query runs for each item.
- Use a **batched query** to load related data in one operation.
- An **ETag** identifies a specific version of a response.
- Clients send ETags back using the request header `If-None-Match`.
- If nothing changed, return `304 Not Modified` with no response body.
- Use the `Cache-Control` header to tell caches what they can store and for how long.
- Use `public, max-age=...` for shared data.
- Use `private, no-store` for personal data.
- Change the ETag whenever the data changes.

## A Real-World Example

Imagine a busy coffee shop. Instead of walking to the storeroom every time someone orders milk, the barista brings enough milk for many drinks in a single trip. The barista also prepares popular drinks in advance, rather than starting from scratch each time.

The goal is simple: avoid repeating work, avoid unnecessary trips, and reuse results whenever possible. Caching follows the same principle.

## What You Will Learn

In this lesson, you will learn:

1. How to identify N+1 query problems.
2. How batched queries improve performance.
3. How ETags and conditional requests work.
4. How Cache-Control affects browsers and caches.
5. How cache invalidation keeps data accurate.

## Fix N+1 with One Batched Query

The solution is to fetch all related data in a single query. Conceptually:

```sql
SELECT *
FROM OrderItems
WHERE OrderId IN (101,102,103,104,105)
```

The application then groups those items in memory.

The response body is unchanged. The difference appears in the header: `X-Db-Queries: 2`

There is only one query for the orders, and one query for the items. Even if there are 1,000 orders, it stays at `2 queries` instead of `1001 queries`.

Every database query has a cost. A few milliseconds may not matter once, but hundreds of small queries add up quickly. In Entity Framework Core, methods such as `.Include(o => o.Items)` often remove common N+1 query problems.

## Skip the Response Body with ETags

Some data changes rarely. For example, menu items, store locations, and product categories. If the client already has the latest copy, there is no reason to send it again.

An **ETag** identifies a specific version of a response. Example: `ETag: "menu-1"`

### First Request

The response is `200 OK` with `ETag: "menu-1"`. The client stores the response and the ETag.

### Second Request

The client sends `If-None-Match: "menu-1"`. Because the data is unchanged, the server returns `304 Not Modified`. No body is sent, and the client continues using its cached copy.

### Third Request

The client sends `If-None-Match: "menu-0"`. The tag is outdated, so the server returns `200 OK` with the latest menu.

Always compare the complete ETag value, including the quotation marks. Compare `"menu-1"`, not `menu-1`. The quotes are part of the ETag value.

## Tell Caches What They May Store

Many caches can exist between a user and your API, such as the browser cache, the mobile cache, a corporate proxy, and a CDN. The `Cache-Control` header defines the caching rules.

### Shared Data

Example: `Cache-Control: public, max-age=300`

This means that any cache may store it, for 300 seconds. The API may not receive another request for five minutes. It is suitable for menus, product catalogs, and public reference data.

### Personal Data

Example: `Cache-Control: private, no-store`

This means that no shared cache may store it, the browser should not keep a copy, and every request must fetch fresh data. It is suitable for orders, user profiles, account information, and payment history.

Never use `public` for user-specific information. Shared caches could accidentally serve the data of one person to another user. Use `private, no-store` for personalized responses.

## Cache Invalidation

Caching only works if cached data stays accurate. When data changes, the cached version must become outdated. A common approach is to build ETags from a version number. Example: `menu-1`, `menu-2`, `menu-3`

Example flow:

### Initial Request

The response has the header `ETag: "menu-1"`.

### Menu Update

The request `PUT /menu/1` changes a menu item. The version number goes from `1 → 2`.

### Next Request

The client sends `If-None-Match: "menu-1"`. The server now responds with `200 OK` and `ETag: "menu-2"`. The stale cache is replaced.

This process is called **cache invalidation**.

## Compression

Large JSON responses often contain repeated text. Response compression reduces the amount of data sent across the network.

Example:

```csharp
builder.Services.AddResponseCompression(
    o => o.EnableForHttps = true);

app.UseResponseCompression();
```

The benefits are smaller responses, faster downloads, and less bandwidth usage. JSON commonly compresses extremely well.

## Output Caching

Instead of running expensive code again for every request, some APIs cache complete responses in memory.

Example:

```csharp
builder.Services.AddOutputCache();

app.UseOutputCache();
```

Example endpoint:

```csharp
app.MapGet("/menu", () => menu)
   .CacheOutput(p =>
       p.Expire(TimeSpan.FromMinutes(5)));
```

The next request can be served directly from the cache, without running the endpoint logic again.

## Measuring Performance with p95 Latency

**Latency** measures how long a request takes. Many teams focus on `p95 latency` rather than averages.

### What p95 Means

If `p95 = 200ms`, then `95% of requests finished in 200ms or less`, and `5% took longer`. Those slow requests often represent real user complaints.

### Why Not Use Only the Average?

Example: `9 requests = 20ms` and `1 request = 500ms`. The average is `68ms`, so the average appears healthy. But one customer still experienced `500ms`.

Monitoring p95 helps reveal those slow experiences.

## Where You See This

- Mobile apps use ETags to avoid downloading identical data repeatedly.
- CDNs cache public responses using Cache-Control rules.
- Shopping and banking systems use `no-store` for personal information.
- Database profiling tools expose N+1 query problems.
- High-traffic APIs rely heavily on caching and compression to reduce load.

## Common mistakes

| Symptom | Cause | Fix |
|---|---|---|
| Performance gets worse as data grows | N+1 queries | Use batched queries or eager loading |
| `304 Not Modified` never occurs | ETags are missing or compared incorrectly | Send and compare complete ETag values |
| Users see outdated information | ETags never change | Update ETags whenever data changes |
| One user sees another user's information | Personalized data is cached publicly | Use `private, no-store` |
| Responses consume too much bandwidth | Compression is disabled | Enable response compression |
| Slow requests are missed | Only averages are monitored | Track p95 and p99 latency |

## Exercises

## What to Remember

- The fastest request is the one that you do not repeat.
- Count your queries. **N+1** grows with your data. A **batched query** stays at 2.
- An **ETag** labels a version. A matching **If-None-Match** gets `304` with no body.
- **Cache-Control** says who may keep a reply: `public, max-age` for shared data, and `private, no-store` for personal data.
- When data changes, change the ETag.

**Next:** Rate Limiting and Resilience. You will protect your API when too many requests arrive at once, and you will keep it working when something that it depends on fails.

var app = builder.Build();

app.MapGet("/orders", (HttpContext context) =>
{
    var db = new FakeDb(); // a fresh counter for every request
    var orders = db.GetOrders(); // trip 1: load the orders

    var result = new List<OrderView>();
    foreach (var order in orders)
    {
        // One more trip for EVERY order. 5 orders = 5 more trips. This is N+1.
        string[] items = db.GetItemsForOrder(order.Id);
        result.Add(new OrderView(order.Id, order.CustomerId, items));
    }

    context.Response.Headers["X-Db-Queries"] = db.Queries.ToString();
    return Results.Ok(result);
});

app.Run();

record Order(int Id, int CustomerId);
record OrderView(int Id, int CustomerId, string[] Items);

// A tiny pretend database. It does not time anything. It only counts trips.
class FakeDb
{
    public int Queries { get; private set; }
    static readonly Order[] Orders = { new(101, 7), new(102, 8), new(103, 7), new(104, 8), new(105, 7) };
    static readonly (int OrderId, string Item)[] Lines =
    {
        (101, "Latte"), (101, "Blueberry Muffin"), (102, "Masala Chai"),
        (103, "Cold Coffee"), (104, "Latte"), (105, "Masala Chai"), (105, "Blueberry Muffin"),
    };

    public Order[] GetOrders() { Queries++; return Orders; }

    public string[] GetItemsForOrder(int orderId)
    {
        Queries++;
        return Lines.Where(l => l.OrderId == orderId).Select(l => l.Item).ToArray();
    }
}`;

type Order = { id: number; customerId: number };

// A tiny pretend database. It does not time anything. It only counts trips.
class FakeDb {
  queries = 0;
  static orders: Order[] = [
    { id: 101, customerId: 7 }, { id: 102, customerId: 8 }, { id: 103, customerId: 7 },
    { id: 104, customerId: 8 }, { id: 105, customerId: 7 },
  ];
  static lines: [number, string][] = [
    [101, "Latte"], [101, "Blueberry Muffin"], [102, "Masala Chai"],
    [103, "Cold Coffee"], [104, "Latte"], [105, "Masala Chai"], [105, "Blueberry Muffin"],
  ];

  getOrders(): Order[] { this.queries++; return FakeDb.orders; }

  getItemsForOrder(orderId: number): string[] {
    this.queries++;
    return FakeDb.lines.filter(([id]) => id === orderId).map(([, item]) => item);
  }
}

const app = new Hono();

app.get("/orders", (c) => {
  const db = new FakeDb(); // a fresh counter for every request
  const orders = db.getOrders(); // trip 1: load the orders

  const result = [];
  for (const order of orders) {
    // One more trip for EVERY order. 5 orders = 5 more trips. This is N+1.
    const items = db.getItemsForOrder(order.id);
    result.push({ id: order.id, customerId: order.customerId, items });
  }

  c.header("X-Db-Queries", String(db.queries));
  return c.json(result, 200);
});

var app = builder.Build();

app.MapGet("/orders", (HttpContext context) =>
{
    var db = new FakeDb(); // a fresh counter for every request
    var orders = db.GetOrders(); // trip 1: load the orders

    // Trip 2: load the items for ALL these orders in one go.
    var itemsByOrder = db.GetItemsForOrders(orders.Select(o => o.Id).ToArray());

    // Now we only match them up in memory. No more trips.
    var result = orders.Select(o => new OrderView(o.Id, o.CustomerId, itemsByOrder[o.Id])).ToList();

    context.Response.Headers["X-Db-Queries"] = db.Queries.ToString();
    return Results.Ok(result);
});

app.Run();

record Order(int Id, int CustomerId);
record OrderView(int Id, int CustomerId, string[] Items);

// A tiny pretend database. It does not time anything. It only counts trips.
class FakeDb
{
    public int Queries { get; private set; }
    static readonly Order[] Orders = { new(101, 7), new(102, 8), new(103, 7), new(104, 8), new(105, 7) };
    static readonly (int OrderId, string Item)[] Lines =
    {
        (101, "Latte"), (101, "Blueberry Muffin"), (102, "Masala Chai"),
        (103, "Cold Coffee"), (104, "Latte"), (105, "Masala Chai"), (105, "Blueberry Muffin"),
    };

    public Order[] GetOrders() { Queries++; return Orders; }

    // One query for many orders, like SQL: WHERE OrderId IN (101, 102, ...)
    public Dictionary<int, string[]> GetItemsForOrders(int[] orderIds)
    {
        Queries++;
        return orderIds.ToDictionary(id => id, id => Lines.Where(l => l.OrderId == id).Select(l => l.Item).ToArray());
    }
}`;

type Order = { id: number; customerId: number };

// A tiny pretend database. It does not time anything. It only counts trips.
class FakeDb {
  queries = 0;
  static orders: Order[] = [
    { id: 101, customerId: 7 }, { id: 102, customerId: 8 }, { id: 103, customerId: 7 },
    { id: 104, customerId: 8 }, { id: 105, customerId: 7 },
  ];
  static lines: [number, string][] = [
    [101, "Latte"], [101, "Blueberry Muffin"], [102, "Masala Chai"],
    [103, "Cold Coffee"], [104, "Latte"], [105, "Masala Chai"], [105, "Blueberry Muffin"],
  ];

  getOrders(): Order[] { this.queries++; return FakeDb.orders; }

  // One query for many orders, like SQL: WHERE OrderId IN (101, 102, ...)
  getItemsForOrders(orderIds: number[]): Map<number, string[]> {
    this.queries++;
    return new Map(orderIds.map((id) => [id, FakeDb.lines.filter(([o]) => o === id).map(([, item]) => item)]));
  }
}

const app = new Hono();

app.get("/orders", (c) => {
  const db = new FakeDb(); // a fresh counter for every request
  const orders = db.getOrders(); // trip 1: load the orders

  // Trip 2: load the items for ALL these orders in one go.
  const itemsByOrder = db.getItemsForOrders(orders.map((o) => o.id));

  // Now we only match them up in memory. No more trips.
  const result = orders.map((o) => ({ id: o.id, customerId: o.customerId, items: itemsByOrder.get(o.id)! }));

  c.header("X-Db-Queries", String(db.queries));
  return c.json(result, 200);
});

var app = builder.Build();

// The menu has a version number. It goes up every time the menu changes.
int version = 1;
var menu = new List<MenuItem>
{
    new(1, "Latte", 220), new(2, "Masala Chai", 120),
    new(3, "Blueberry Muffin", 150), new(4, "Cold Coffee", 180),
};

app.MapGet("/menu", (HttpContext context) =>
{
    // The ETag is a short label for this exact version of the menu.
    string etag = $"\\"menu-{version}\\"";
    context.Response.Headers["ETag"] = etag;

    // The client says "I already have this version". Then send no body at all.
    if (context.Request.Headers["If-None-Match"].ToString() == etag)
        return Results.StatusCode(304);

    return Results.Ok(menu);
});

app.Run();

record MenuItem(int Id, string Name, int Price);`;

type MenuItem = { id: number; name: string; price: number };

// The menu has a version number. It goes up every time the menu changes.
let version = 1;
const menu: MenuItem[] = [
  { id: 1, name: "Latte", price: 220 }, { id: 2, name: "Masala Chai", price: 120 },
  { id: 3, name: "Blueberry Muffin", price: 150 }, { id: 4, name: "Cold Coffee", price: 180 },
];

const app = new Hono();

app.get("/menu", (c) => {
  // The ETag is a short label for this exact version of the menu.
  const etag = \`"menu-\${version}"\`;
  c.header("ETag", etag);

  // The client says "I already have this version". Then send no body at all.
  if (c.req.header("If-None-Match") === etag) return c.body(null, 304);

  return c.json(menu, 200);
});

###
GET /menu
If-None-Match: "menu-1"

###
GET /menu
If-None-Match: "menu-0"`;

var app = builder.Build();

var menu = new[]
{
    new MenuItem(1, "Latte", 220), new MenuItem(2, "Masala Chai", 120),
    new MenuItem(3, "Blueberry Muffin", 150), new MenuItem(4, "Cold Coffee", 180),
};

// The menu is the same for everyone. Any cache may keep it for 300 seconds (5 minutes).
app.MapGet("/menu", (HttpContext context) =>
{
    context.Response.Headers["Cache-Control"] = "public, max-age=300";
    return Results.Ok(menu);
});

// "My orders" belong to one customer (here: Asha, customer 7).
// No cache may keep a copy, not even the browser.
app.MapGet("/orders/mine", (HttpContext context) =>
{
    context.Response.Headers["Cache-Control"] = "private, no-store";
    return Results.Ok(new[] { new { id = 101, customer = "customer-7", items = new[] { "Latte", "Blueberry Muffin" } } });
});

app.Run();

record MenuItem(int Id, string Name, int Price);`;

const menu = [
  { id: 1, name: "Latte", price: 220 }, { id: 2, name: "Masala Chai", price: 120 },
  { id: 3, name: "Blueberry Muffin", price: 150 }, { id: 4, name: "Cold Coffee", price: 180 },
];

const app = new Hono();

// The menu is the same for everyone. Any cache may keep it for 300 seconds (5 minutes).
app.get("/menu", (c) => {
  c.header("Cache-Control", "public, max-age=300");
  return c.json(menu, 200);
});

// "My orders" belong to one customer (here: Asha, customer 7).
// No cache may keep a copy, not even the browser.
app.get("/orders/mine", (c) => {
  c.header("Cache-Control", "private, no-store");
  return c.json([{ id: 101, customer: "customer-7", items: ["Latte", "Blueberry Muffin"] }], 200);
});

###
GET /orders/mine`;

var app = builder.Build();

int version = 1;
var menu = new List<MenuItem>
{
    new(1, "Latte", 220), new(2, "Masala Chai", 120),
    new(3, "Blueberry Muffin", 150), new(4, "Cold Coffee", 180),
};

app.MapGet("/menu", (HttpContext context) =>
{
    string etag = $"\\"menu-{version}\\"";
    context.Response.Headers["ETag"] = etag;
    if (context.Request.Headers["If-None-Match"].ToString() == etag)
        return Results.StatusCode(304);
    return Results.Ok(menu);
});

app.MapPut("/menu/{id}", (int id, PriceChange change) =>
{
    int index = menu.FindIndex(m => m.Id == id);
    if (index < 0) return Results.NotFound();

    menu[index] = menu[index] with { Price = change.Price };
    // The menu changed, so its version (and its ETag) must change too.
    // Old copies with "menu-1" are now stale and will get the new menu.
    version++;
    return Results.Ok(menu[index]);
});

app.Run();

record MenuItem(int Id, string Name, int Price);
record PriceChange(int Price);`;

type MenuItem = { id: number; name: string; price: number };

let version = 1;
const menu: MenuItem[] = [
  { id: 1, name: "Latte", price: 220 }, { id: 2, name: "Masala Chai", price: 120 },
  { id: 3, name: "Blueberry Muffin", price: 150 }, { id: 4, name: "Cold Coffee", price: 180 },
];

const app = new Hono();

app.get("/menu", (c) => {
  const etag = \`"menu-\${version}"\`;
  c.header("ETag", etag);
  if (c.req.header("If-None-Match") === etag) return c.body(null, 304);
  return c.json(menu, 200);
});

app.put("/menu/:id", async (c) => {
  const item = menu.find((m) => m.id === Number(c.req.param("id")));
  if (!item) return c.body(null, 404);

  const change = await c.req.json();
  item.price = change.price;
  // The menu changed, so its version (and its ETag) must change too.
  // Old copies with "menu-1" are now stale and will get the new menu.
  version++;
  return c.json(item, 200);
});

If-None-Match: "menu-1"

###
PUT /menu/1
Content-Type: application/json

{"price": 240}

###
GET /menu
If-None-Match: "menu-1"`;

  {
    prompt: "Fix the batched count, so that it makes one trip for the orders and one trip for all their items, for any N.",
    hint: "Batched does not need a loop at all. Add one query for the items.",
    explain: "Naive grows with N (1 + N). Batched stays at 2, because all the items come back in one trip.",
    csharp: { starter: `// Count database trips to load N orders and their items.
// Naive: 1 trip for the orders, then 1 trip per order (1 + N).
// Batched: 1 trip for the orders, then 1 trip for ALL their items (2).
// TODO: fix Batched so it makes only one trip for the items.
static int Naive(int n)
{
    int queries = 1;                       // load the orders
    for (int i = 0; i < n; i++) queries++; // load items, one order at a time
    return queries;
}

static int Batched(int n)
{
    int queries = 1;
    for (int i = 0; i < n; i++) queries++; // still one trip per order!
    return queries;
}

foreach (int n in new[] { 1, 10, 100 })
    Console.WriteLine($"N={n}: naive {Naive(n)}, batched {Batched(n)}");`, solution: `// Count database trips to load N orders and their items.
// Naive: 1 trip for the orders, then 1 trip per order (1 + N).
// Batched: 1 trip for the orders, then 1 trip for ALL their items (2).
static int Naive(int n)
{
    int queries = 1;                       // load the orders
    for (int i = 0; i < n; i++) queries++; // load items, one order at a time
    return queries;
}

static int Batched(int n)
{
    int queries = 1;
    queries++; // load the items for all N orders in one trip
    return queries;
}

foreach (int n in new[] { 1, 10, 100 })
    Console.WriteLine($"N={n}: naive {Naive(n)}, batched {Batched(n)}");`, mode: "console" },
    react: { starter: `// Count database trips to load N orders and their items.
// Naive: 1 trip for the orders, then 1 trip per order (1 + N).
// Batched: 1 trip for the orders, then 1 trip for ALL their items (2).
// TODO: fix batched so it makes only one trip for the items.
function naive(n: number): number {
  let queries = 1;                         // load the orders
  for (let i = 0; i < n; i++) queries++;   // load items, one order at a time
  return queries;
}

function batched(n: number): number {
  let queries = 1;
  for (let i = 0; i < n; i++) queries++;   // still one trip per order!
  return queries;
}

for (const n of [1, 10, 100]) {
  console.log(\`N=\${n}: naive \${naive(n)}, batched \${batched(n)}\`);
}`, solution: `// Count database trips to load N orders and their items.
// Naive: 1 trip for the orders, then 1 trip per order (1 + N).
// Batched: 1 trip for the orders, then 1 trip for ALL their items (2).
function naive(n: number): number {
  let queries = 1;                         // load the orders
  for (let i = 0; i < n; i++) queries++;   // load items, one order at a time
  return queries;
}

function batched(n: number): number {
  let queries = 1;
  queries++; // load the items for all N orders in one trip
  return queries;
}

for (const n of [1, 10, 100]) {
  console.log(\`N=\${n}: naive \${naive(n)}, batched \${batched(n)}\`);
}`, mode: "console" }
  },
  {
    prompt: "Give GET /menu an ETag that is built from the version, such as \"menu-1\". When If-None-Match matches it, return 304 with no body.",
    hint: "Build the tag with quotes, set the ETag header first, and then compare it with the If-None-Match request header.",
    explain: "The first GET gets the menu and its ETag. The second GET sends that tag back and gets a tiny 304, so no menu is sent twice.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

int version = 1;
var menu = new[] { new MenuItem(1, "Latte", 220), new MenuItem(2, "Masala Chai", 120) };

// TODO: send the header ETag: "menu-1" (built from version, with the quotes).
// If the request's If-None-Match equals that ETag, return 304 with no body.
app.MapGet("/menu", (HttpContext context) =>
{
    return Results.Ok(menu);
});

app.Run();

record MenuItem(int Id, string Name, int Price);`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

int version = 1;
var menu = new[] { new MenuItem(1, "Latte", 220), new MenuItem(2, "Masala Chai", 120) };

app.MapGet("/menu", (HttpContext context) =>
{
    string etag = $"\\"menu-{version}\\"";
    context.Response.Headers["ETag"] = etag;
    if (context.Request.Headers["If-None-Match"].ToString() == etag)
        return Results.StatusCode(304);
    return Results.Ok(menu);
});

app.Run();

record MenuItem(int Id, string Name, int Price);`, mode: "aspnet", requests: `GET /menu

###
GET /menu
If-None-Match: "menu-1"` },
    react: { starter: `import { Hono } from "hono";

let version = 1;
const menu = [{ id: 1, name: "Latte", price: 220 }, { id: 2, name: "Masala Chai", price: 120 }];

const app = new Hono();

// TODO: send the header ETag: "menu-1" (built from version, with the quotes).
// If the request's If-None-Match equals that ETag, return 304 with no body.
app.get("/menu", (c) => {
  return c.json(menu, 200);
});

let version = 1;
const menu = [{ id: 1, name: "Latte", price: 220 }, { id: 2, name: "Masala Chai", price: 120 }];

const app = new Hono();

app.get("/menu", (c) => {
  const etag = \`"menu-\${version}"\`;
  c.header("ETag", etag);
  if (c.req.header("If-None-Match") === etag) return c.body(null, 304);
  return c.json(menu, 200);
});

###
GET /menu
If-None-Match: "menu-1"` },
    checks: `GET /menu
If-None-Match: "menu-2"

###
GET /menu
If-None-Match: "menu-1"`
  },
  {
    prompt: "Add Cache-Control: public, max-age=300 to GET /menu, and add Cache-Control: private, no-store to GET /orders/mine.",
    hint: "Set the Cache-Control response header in each handler before you return.",
    explain: "The menu is the same for everyone, so any cache may keep it for 5 minutes. Personal orders must never be stored by a shared cache, or one customer could see the orders of another.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var menu = new[] { new MenuItem(1, "Latte", 220), new MenuItem(2, "Masala Chai", 120) };

// TODO: the menu is the same for everyone. Send Cache-Control: public, max-age=300
app.MapGet("/menu", (HttpContext context) =>
{
    return Results.Ok(menu);
});

// TODO: these orders belong to one customer. Send Cache-Control: private, no-store
app.MapGet("/orders/mine", (HttpContext context) =>
{
    return Results.Ok(new[] { new { id = 101, item = "Latte" } });
});

app.Run();

record MenuItem(int Id, string Name, int Price);`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var menu = new[] { new MenuItem(1, "Latte", 220), new MenuItem(2, "Masala Chai", 120) };

app.MapGet("/menu", (HttpContext context) =>
{
    context.Response.Headers["Cache-Control"] = "public, max-age=300";
    return Results.Ok(menu);
});

app.MapGet("/orders/mine", (HttpContext context) =>
{
    context.Response.Headers["Cache-Control"] = "private, no-store";
    return Results.Ok(new[] { new { id = 101, item = "Latte" } });
});

app.Run();

record MenuItem(int Id, string Name, int Price);`, mode: "aspnet", requests: `GET /menu

###
GET /orders/mine` },
    react: { starter: `import { Hono } from "hono";

const menu = [{ id: 1, name: "Latte", price: 220 }, { id: 2, name: "Masala Chai", price: 120 }];

const app = new Hono();

// TODO: the menu is the same for everyone. Send Cache-Control: public, max-age=300
app.get("/menu", (c) => {
  return c.json(menu, 200);
});

// TODO: these orders belong to one customer. Send Cache-Control: private, no-store
app.get("/orders/mine", (c) => {
  return c.json([{ id: 101, item: "Latte" }], 200);
});

const menu = [{ id: 1, name: "Latte", price: 220 }, { id: 2, name: "Masala Chai", price: 120 }];

const app = new Hono();

app.get("/menu", (c) => {
  c.header("Cache-Control", "public, max-age=300");
  return c.json(menu, 200);
});

app.get("/orders/mine", (c) => {
  c.header("Cache-Control", "private, no-store");
  return c.json([{ id: 101, item: "Latte" }], 200);
});

###
GET /orders/mine` },
    checks: `GET /menu?page=2

###
GET /orders/mine?fresh=1`
  }
];
