DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Five sequential awaits before a cache hit could leave the building

The product lookup route is Munchable's core loop. A barcode comes in, a product goes out, and the app's felt latency is this route's latency. It was doing something very ordinary and very slow: awaiting five network-shaped things one after another, none of which needed the previous one's result.

auth
  -> rate limit check one
    -> rate limit check two
      -> taxonomy version header
        -> product cache GET
Enter fullscreen mode Exit fullscreen mode

A warm cache hit could not leave until all five had returned in series. Here is what changed, and the one ordering that must not change.

Start everything, await where a dependency forces it

// Four network-shaped hops used to run strictly one after another before a
// warm cache hit could even leave the building: auth, the two rate-limit
// checks (themselves sequential), the taxonomy version header, and the
// product cache GET. None of the first four actually needs another's
// result as input, so they are now started together and awaited only where
// a real data dependency forces it. What follows is deliberately more
// heavily commented than usual, because getting the ORDER of awaits wrong
// here is exactly how a 401 would end up carrying product data or a
// rate-limited request would end up serving a hit.
Enter fullscreen mode Exit fullscreen mode

Auth and the taxonomy header start before the body is even parsed. The cache read starts the instant the barcode is known, in parallel with auth still in flight:

const userPromise = getUser(request);
const taxonomyHeadersPromise = taxonomyVersionHeaders();
...
const cachePromise = barcode ? cacheGet(barcode) : null;
...
const user = await userPromise;
if (!user) {
  cachePromise?.catch(() => {});
  taxonomyHeadersPromise.catch(() => {});
  return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
Enter fullscreen mode Exit fullscreen mode

The cache result is never read until after the user is confirmed, and on the 401 path it is discarded outright with its rejection swallowed. That is the whole safety argument, and the comment states the cost honestly: an unauthenticated flood, which previously failed at auth with zero Redis calls, now costs one extra Redis GET per request before the same 401. If that ever became the abuse vector, the fix is a cheap limiter in front of that one read, not re-serialising it behind auth again.

Two rate limiters in one round trip

The route checks a per-user limit and a per-IP limit. The general-purpose enforce helper runs its checks in order and short-circuits on the first failure, which is right for callers where the order carries meaning. Here it just cost a round trip:

export async function enforceParallel(
  checks: Array<{ limiter: Ratelimit; key: string }>,
): Promise<LimitDecision> {
  const results = await Promise.all(checks.map(({ limiter, key }) => limiter.limit(key)));
  for (const r of results) {
    if (!r.success) return { success: false, limit: r.limit, remaining: r.remaining, reset: r.reset };
  }
  return { success: true };
}
Enter fullscreen mode Exit fullscreen mode

It is a separate function rather than a flag because it always increments every bucket, even when the first one would have rejected. The order-sensitive callers keep the loop.

Hand the started read to the function that would repeat it

lookupProduct used to do its own cache read as its first step. Now it accepts the promise the route already started:

export async function lookupProduct(
  barcode: string,
  prefetchedCache?: Promise<LookupResult | null>,
): Promise<LookupResult> {
  const cached = prefetchedCache !== undefined ? await prefetchedCache : await cacheGet(barcode);
  if (cached) return cached;
Enter fullscreen mode Exit fullscreen mode

By the time the route calls it, that promise has usually already settled, because it was started before auth and the rate limiter, both of which have just run. Threading it through rather than calling cacheGet twice is what keeps the read happening exactly once.

The one thing that had to move after the response

On a warm hit the response now leaves in a few milliseconds, which broke the scan counter. A serverless instance can be frozen the moment the response is sent, so a fire-and-forget increment after the return sometimes never lands, and it under-counts exactly the most popular barcodes. The increment is in after(), which keeps the work inside the invocation:

after(() => countScan(barcode));
Enter fullscreen mode Exit fullscreen mode

The client side of the same change

The app used to start the lookup only once its two-frame stability hold had passed. It now fires a speculative lookup on the first valid read and joins the same promise when the second frame confirms, so the round trip runs underneath the hold. That let the hold come down from 300 ms to 120 ms. The client story is its own post: 120 milliseconds is not a delay, it is proof of a second frame.

Together the two changes mean a confirmed scan of a known product is often over in well under 200 ms. To feel it, sign in, choose "Continue in browser", and scan something twice. The second scan is served from the device cache with no request at all, and the first is the route above.

Earlier posts covered what the app does when Redis is not there and what to key ten rate limiters on. This one is about the order you await them in.

Top comments (0)