Redundant Work in Node.js: A 311-Repository Study on Memoization and Request-Scoped Caching

by Ko-Hsin Liang

Redundant Work in Node.js: A 311-Repository Study on Memoization and Request-Scoped Caching

I scanned 311 Node.js repositories and found 1,247 cache opportunities across 412 files. The thing that stood out wasn’t missing Redis—it was the same work happening twice in the same request. When identical inputs produce identical outputs in the same runtime, you’re looking at wasted cycles you can eliminate with confidence.

The Pattern

Throughout this study, I use the term cache opportunity to mean a specific thing: repeated computation or data fetches that can be eliminated safely because the inputs are stable. Not a missing cache layer — duplicate work that’s already happening. This happens when a pure function is called twice with identical arguments in the same request, when the same API endpoint is fetched multiple times, or when the same database or GraphQL query runs more than once for a single user request.

In these cases, caching is not speculative. The program is already doing duplicate work.

Why This Matters

Node apps are built around request flows. A single page render, API request, or background job can trigger multiple helpers and utilities, sometimes calling the same function or query twice. One duplicated call may not hurt. But across many requests—CPU time for repeated compute, latency for repeated HTTP fetches, load on your database for repeated queries—the cost becomes real. Once you see the pattern, most of the work is just classifying the repeated call correctly.

What I Found

1,247 cache opportunities across 412 files. The breakdown: 489 repeated pure compute findings (39%)—functions like normalizeUrlArgument, parse, and buildSignature called multiple times with identical inputs. 386 repeated HTTP fetches (31%)—the same endpoints called twice in test suites and request handlers. 372 repeated DB/GraphQL queries (30%)—identical queries executed more than once per request.

The strongest signal came from repeated pure compute, particularly URL parsing and normalization. In Express, Koa, and Fastify, I saw parse(this.req) called 4–6 times per request handler even though the result never changed. The most repeated pure compute was normalizeUrlArgument. The most repeated HTTP fetch pattern was http.get('http://localhost:${port}', ...) repeated 8 times in a single test file.

By framework: Express (87 findings) for URL parsing and error formatting. Koa (72 findings) for request parsing repeated across middleware. Fastify (68 findings) for HTTP test utilities with duplicate fetches. TypeORM (54 findings) for repeated ORM queries. Apollo Server (41 findings) for GraphQL resolver patterns.

This tells us one thing: the code is already doing duplicate work. Caching is not optional. It is the natural fix.

A real example: Koa URL parsing

Koa’s middleware chain parses this.req multiple times unnecessarily. Here’s what was found in production:

Before (inefficient, found in 72 Koa codebases):

app.use(async (ctx, next) => {
  const parsed1 = url.parse(ctx.req.url);  // First parse
  ctx.hostname = parsed1.hostname;
  
  await next();
  
  // Downstream middleware
});

app.use(async (ctx, next) => {
  const parsed2 = url.parse(ctx.req.url);  // Second parse, same input
  const pathname = parsed2.pathname;
  ctx.path = pathname;
  
  await next();
});

app.use(async (ctx, next) => {
  const parsed3 = url.parse(ctx.req.url);  // Third parse
  ctx.search = parsed3.search;
});

After (cached at request scope):

app.use(async (ctx, next) => {
  // Parse once and attach to context
  ctx.cachedUrlParse = url.parse(ctx.req.url);
  await next();
});

app.use(async (ctx, next) => {
  ctx.hostname = ctx.cachedUrlParse.hostname;
  await next();
});

app.use(async (ctx, next) => {
  ctx.path = ctx.cachedUrlParse.pathname;
  ctx.search = ctx.cachedUrlParse.search;
  await next();
});

The fix is simple: parse once, pass the result through context. The request-scoped cache (attached to ctx) automatically resets between requests.

Three Categories: How to Fix Them

Cached patterns map directly to the findings above:

1. Repeated Pure Compute

This is the easiest to fix and the safest. A pure function is one whose output depends only on its inputs, with no side effects or reliance on external state. If you call it twice with the same input in the same request, memoize it—store the result so the second call just retrieves the cached value instead of recomputing.

// WRONG: Global cache leaks data across requests
const globalCache = new Map();

function normalizeUrlArgument(arg) {
  const key = JSON.stringify(arg);
  if (globalCache.has(key)) return globalCache.get(key);
  
  const normalized = expensiveNormalize(arg);
  globalCache.set(key, normalized);
  return normalized;
}

// RIGHT: Request-scoped cache, isolated per request
function createRequestCache() {
  const cache = new Map();
  
  return function normalizeUrlArgument(arg) {
    const key = JSON.stringify(arg);
    if (cache.has(key)) return cache.get(key);
    
    const normalized = expensiveNormalize(arg);
    cache.set(key, normalized);
    return normalized;
  };
}

// Usage: create a new memoizer per request, don't reuse across requests
app.use((req, res, next) => {
  req.normalizeUrl = createRequestCache();
  next();
});

Critical: Always scope the cache to a single request. A global cache in Node.js will leak data across concurrent requests—user A’s normalized URLs could be returned to user B. Request-scoped caches eliminate this risk entirely.

2. Repeated Remote Fetches

Not about global caching. It’s about avoiding duplicate work in the same runtime flow. A page component asks the backend for user metadata, and two downstream helpers ask for the same metadata again. If the request already has the data, don’t fetch it again.

Note the distinction: deduplication (preventing concurrent identical requests from running in parallel) is different from caching (reusing a result from a prior request). The pattern below does both—it returns an in-flight promise if the request is already running, and caches the result for sequential calls:

This is a slightly more advanced pattern—skip to the usage line (app.use((req, res, next) => ...) if you just want to see how it plugs in.

// Per-request cache and in-flight tracking
function createFetchCache() {
  const cache = new Map();
  const inFlight = new Map();
  
  return async function fetchUserProfile(userId) {
    // If already cached, return immediately
    if (cache.has(userId)) return cache.get(userId);
    
    // If already fetching, return the same promise (deduplication)
    if (inFlight.has(userId)) return inFlight.get(userId);
    
    // Start the fetch and track it
    const promise = api.get(`/users/${userId}`);
    inFlight.set(userId, promise);
    
    try {
      const profile = await promise;
      cache.set(userId, profile);
      return profile;
    } finally {
      inFlight.delete(userId);
    }
  };
}

app.use((req, res, next) => {
  req.fetchUserProfile = createFetchCache();
  next();
});

The same inputs produce the same response in the same request.

3. Repeated Database Queries

This is where thinking only in terms of Redis blocks progress. I found repeated query signatures—the same SQL or GraphQL request built more than once. When the query is identical and the data is expected to be the same in the same request, cache it with request-scoped caching, a DataLoader-style batcher, or by collapsing duplicate GraphQL resolver calls.

DataLoader is a pattern (popularized in GraphQL but applicable anywhere) that batches queries made in the same request cycle: instead of running 5 separate findUser(id) calls, DataLoader collects them and executes a single findUsersByIds([...]) query. For repeated identical queries with identical parameters, a simple request-scoped Map is faster and less complex. For varying parameters that could batch (like getUser(1), getUser(2), getUser(3)), DataLoader shines.

The critical piece is correctness: the query must be identical and the context must not change the result.

Spotting Real Opportunities

A cache opportunity is not just “some repeated code.” It must meet two conditions: the repeated call is deterministic for the same inputs, and it happens in the same request or short-lived workflow.

If the repeated call depends on time, current user session, or a changing external resource, different rules apply. A request that fetches /pricing?ts=... is probably not safe to memoize. But if the same helper calls normalizeUrlArgument twice with the same arguments, that is safe.

Correctness First

The biggest mistake with caching is treating it like a performance hack. If the cached value becomes stale or the repeated call is only logically identical in one branch, you introduce bugs faster than you save cycles.

Example of what goes wrong: if you cache user permissions globally (not request-scoped), user A might see user B’s permissions in a concurrent request because the cached value hasn’t been cleared. Or if you cache a timestamp-dependent result across requests, stale data builds up. These bugs are hard to reproduce and easy to ship.

The right order: identify repeated work, verify the inputs are stable, choose the smallest cache boundary that preserves correctness, measure the benefit. That’s also how to teach this: start with obvious cases and don’t overcache.

The Three Levels

Repeated, deterministic work is wasted work. In Node.js this happens at three levels: function level (the same pure helper runs twice), request level (the same API or database call runs twice), session or edge level (the same response cached for multiple requests).

This study focuses on the first two because they’re easiest to prove and have the highest confidence. A repeated pure compute finding is low-risk—it’s local to the code path, and if the function is pure, caching doesn’t change the output. A repeated query finding is medium-risk because you must verify the query parameters are stable. But that still beats guessing at cross-request caching needs.

The third tier (session or edge caching) is a different problem: expiration logic, invalidation, user isolation, and cache coherence all matter. It’s out of scope for this study, which focuses on the safe, high-confidence wins you can ship today.

Remember

Duplicate work first, not just missing Redis. If the same input produces the same output, memoize it. If the same query runs twice in the same request, keep and reuse it. Cache invalidation is real, so prefer small, short-lived caches. The safest improvement is request-scoped reuse, not global state.

Start Here

Trace a request flow and identify repeated work. Ask: “Are the inputs the same?” If yes, you found a cache opportunity. Check whether a request object, context object, or local cache can carry the result. Keep the cache boundary small: one request, one worker job, one render pass. Add a test that proves the result is reused and still correct.

The Data

I scanned 2,847 files across 311 repositories and measured real repeated work patterns. Clear signals emerged.

By type: pure compute is safest to fix (zero correctness risk when inputs are stable). HTTP fetches have highest impact—eliminating a redundant fetch typically saves the full round-trip latency, commonly 50–200ms on local networks and more on external APIs. Database queries have highest leverage (reduces query count by 70–90% in N+1 patterns where the same ID is fetched multiple times).

By framework: URL parsing in Express and Koa calling parse() on the same request 4–6 times. Error formatting in test suites with parseError() repeated identically 4–8 times. GraphQL resolvers with repeated client.query() without batching. ORM patterns with findMany() using identical where clauses called twice in the same flow.

The key insight: most cache opportunities don’t require infrastructure. They require recognizing work that’s already happening and asking: “Do I really need to do this again?”

Study Methodology

For readers interested in reproducing or extending this work:

Corpus selection: The 311 repositories were selected from npm packages with 1,000+ weekly downloads, filtered to Node.js projects with public GitHub source. This ensures the findings reflect real production patterns in widely-used libraries, though it excludes private codebases and very new projects.

Scanner approach: The AST scanner looked for three patterns:

  1. Repeated pure compute: Detected function calls with identical argument tokens within the same scope (block, function body, or request handler). The scanner flagged any call to the same function with JSON-stringified arguments appearing more than once. Pure functions were identified via structural analysis (no external variable mutations, no I/O calls) and naming convention (avoid functions prefixed with fetch_, get_, or load_).

  2. Repeated HTTP fetches: Detected consecutive calls to fetch(), http.get(), axios.get(), or similar patterns with identical URL arguments in the same scope. The scanner tracked in-flight promise identities and argument values to find duplicates.

  3. Repeated DB/GraphQL queries: Detected repeated query(), find(), findMany(), or client.query() calls with identical arguments (WHERE clauses, variables, etc.) in the same scope. For GraphQL, identical query text with identical variables was flagged.

False positives and validation: The scanner has a known limitation: it cannot always prove purity of a function or rule out mutations to captured variables. We estimated the false positive rate at 15–25% by manually sampling 200 findings across all three categories (roughly 80 pure compute, 60 HTTP, 60 DB/GraphQL). The true findings (1,050–1,254 opportunities) are the conservative lower bound. All reported findings passed at least structural validation (identical arguments or call signatures), but some may not be safe to cache in practice due to hidden side effects. This is why the “Correctness First” section matters: even with a scanner, human judgment about mutability and side effects is essential before implementing a fix.

The Takeaway

Caching opportunities are easier to spot than they look. Start by asking: “Is this the same work being done twice?” If yes, caching is not optional. It is the natural fix. The better answer is usually not more infrastructure. It is better reuse inside the request flow.


Detect missing caching opportunities in your codebase

The patterns documented in this study — repeated expensive function calls within the same scope, repeated HTTP fetches with identical URLs, repeated database queries with identical arguments, and memoization opportunities in render functions — are automatically detected by Code Evolution Lab. The MissingCachingDetector identifies pure functions without memoization, repeated findAll/findMany calls, and API requests in high-frequency contexts that lack cache identifiers.

Scan your repository free → codeevolutionlab.com


Built at Stack Insight.