home / skills / andrueandersoncs / claude-skill-effect-ts / batching-caching

batching-caching skill

/skills/batching-caching

This skill helps you optimize API usage by automatically batching requests and caching results to reduce N+1 queries.

npx playbooks add skill andrueandersoncs/claude-skill-effect-ts --skill batching-caching

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
7.9 KB
---
name: Batching and Caching
description: This skill should be used when the user asks about "Effect batching", "request batching", "Effect caching", "Cache", "Request", "RequestResolver", "Effect.cached", "Effect.cachedWithTTL", "automatic batching", "N+1 problem", "data loader pattern", "deduplication", or needs to understand how Effect optimizes API calls through batching and caching.
version: 1.0.0
---

# Batching and Caching in Effect

## Overview

Effect provides automatic optimization for API calls:

- **Batching** - Combine multiple requests into single API calls
- **Caching** - Avoid redundant requests with smart caching
- **Deduplication** - Prevent duplicate concurrent requests

This solves the N+1 query problem automatically.

## The Problem: N+1 Queries

```typescript
const program = Effect.gen(function* () {
  const todos = yield* getTodos()

  const owners = yield* Effect.forEach(
    todos,
    (todo) => getUserById(todo.ownerId),
    { concurrency: "unbounded" }
  )
})
```

Effect's batching transforms this into optimized batch calls.

## Request-Based Batching

### Step 1: Define Request Types

```typescript
import { Request } from "effect"

// Define request shape
interface GetUserById extends Request.Request<User, UserNotFound> {
  readonly _tag: "GetUserById"
  readonly id: number
}

// Create tagged constructor
const GetUserById = Request.tagged<GetUserById>("GetUserById")
```

### Step 2: Create Resolver

```typescript
import { RequestResolver, Effect } from "effect"

// Batched resolver - handles multiple requests at once
const GetUserByIdResolver = RequestResolver.makeBatched(
  (requests: ReadonlyArray<GetUserById>) =>
    Effect.gen(function* () {
      // Single batch API call
      const users = yield* Effect.tryPromise(() =>
        fetch("/api/users/batch", {
          method: "POST",
          body: JSON.stringify({ ids: requests.map((r) => r.id) })
        }).then((res) => res.json())
      )

      // Complete each request with its result
      yield* Effect.forEach(requests, (request, index) =>
        Request.completeEffect(
          request,
          Effect.succeed(users[index])
        )
      )
    })
)
```

### Step 3: Define Query

```typescript
const getUserById = (id: number) =>
  Effect.request(GetUserById({ id }), GetUserByIdResolver)
```

### Step 4: Use with Automatic Batching

```typescript
const program = Effect.gen(function* () {
  const todos = yield* getTodos()

  const owners = yield* Effect.forEach(
    todos,
    (todo) => getUserById(todo.ownerId),
    { concurrency: "unbounded" }
  )
})
```

## Resolver Types

### Standard Resolver (No Batching)

```typescript
const SingleUserResolver = RequestResolver.fromEffect(
  (request: GetUserById) =>
    Effect.tryPromise(() =>
      fetch(`/api/users/${request.id}`).then((r) => r.json())
    )
)
```

### Batched Resolver

```typescript
const BatchedUserResolver = RequestResolver.makeBatched(
  (requests: ReadonlyArray<GetUserById>) =>
    // Handle all requests in one call
    batchFetch(requests)
)
```

### Resolver with Context

```typescript
const UserResolverWithContext = RequestResolver.makeBatched(
  (requests: ReadonlyArray<GetUserById>) =>
    Effect.gen(function* () {
      // Access services from context
      const httpClient = yield* HttpClient
      const logger = yield* Logger

      yield* logger.info(`Batching ${requests.length} user requests`)

      return yield* httpClient.post("/api/users/batch", {
        ids: requests.map((r) => r.id)
      })
    })
)

// Provide context to resolver
const ContextualResolver = UserResolverWithContext.pipe(
  RequestResolver.provideContext(context)
)
```

## Caching

### Effect.cached - Memoize Effect Result

```typescript
import { Effect } from "effect"

const fetchConfig = Effect.promise(() =>
  fetch("/api/config").then((r) => r.json())
)

const cachedConfig = yield* Effect.cached(fetchConfig)

const config1 = yield* cachedConfig
const config2 = yield* cachedConfig
```

### Effect.cachedWithTTL - Time-Based Expiry

```typescript
const cachedUser = yield* Effect.cachedWithTTL(
  fetchCurrentUser,
  "5 minutes"
)

const user1 = yield* cachedUser
yield* Effect.sleep("6 minutes")
const user2 = yield* cachedUser
```

### Effect.cachedInvalidateWithTTL - Manual Invalidation

```typescript
const [cachedUser, invalidate] = yield* Effect.cachedInvalidateWithTTL(
  fetchCurrentUser,
  "5 minutes"
)

const user = yield* cachedUser
yield* invalidate
const freshUser = yield* cachedUser
```

## Cache Service

For more control, use the Cache service:

```typescript
import { Cache } from "effect"

const program = Effect.gen(function* () {
  const cache = yield* Cache.make({
    capacity: 100,
    timeToLive: "10 minutes",
    lookup: (userId: string) => fetchUser(userId)
  })

  const user1 = yield* cache.get("user-1")
  const user2 = yield* cache.get("user-1")

  const isCached = yield* cache.contains("user-1")

  yield* cache.invalidate("user-1")

  const stats = yield* cache.cacheStats
})
```

## Request Caching

Requests are automatically cached within a query context:

```typescript
const program = Effect.gen(function* () {
  const user1 = yield* getUserById(1)
  const user2 = yield* getUserById(1)

  const user3 = yield* getUserById(2)
})
```

### Disabling Request Caching

```typescript
const noCaching = getUserById(1).pipe(
  Effect.withRequestCaching(false)
)
```

### Custom Cache for Requests

```typescript
const customCache = yield* Request.makeCache({
  capacity: 1000,
  timeToLive: "30 minutes"
})

const program = getUserById(1).pipe(
  Effect.withRequestCache(customCache)
)
```

## Disabling Batching

```typescript
const noBatching = program.pipe(
  Effect.withRequestBatching(false)
)
```

## Complete Example

```typescript
import { Effect, Request, RequestResolver, Schema } from "effect"

// Error types
class UserNotFound extends Schema.TaggedError<UserNotFound>()(
  "UserNotFound",
  { id: Schema.Number }
) {}

// Request type
interface GetUserById extends Request.Request<User, UserNotFound> {
  readonly _tag: "GetUserById"
  readonly id: number
}
const GetUserById = Request.tagged<GetUserById>("GetUserById")

// Batched resolver
const UserResolver = RequestResolver.makeBatched(
  (requests: ReadonlyArray<GetUserById>) =>
    Effect.gen(function* () {
      const ids = requests.map((r) => r.id)

      const response = yield* Effect.tryPromise({
        try: () =>
          fetch("/api/users/batch", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ ids })
          }).then((r) => r.json() as Promise<User[]>),
        catch: () => new Error("Batch fetch failed")
      })

      yield* Effect.forEach(requests, (request, index) => {
        const user = response[index]
        return user
          ? Request.completeEffect(request, Effect.succeed(user))
          : Request.completeEffect(
              request,
              Effect.fail(new UserNotFound({ id: request.id }))
            )
      })
    })
)

// Query function
const getUserById = (id: number) =>
  Effect.request(GetUserById({ id }), UserResolver)

// Usage - automatically batched
const program = Effect.gen(function* () {
  const todos = yield* getTodos()

  const owners = yield* Effect.forEach(
    todos,
    (todo) => getUserById(todo.ownerId),
    { concurrency: "unbounded" }
  )

  return owners
})
```

## Best Practices

1. **Use batching for N+1 scenarios** - Especially with databases/APIs
2. **Cache expensive computations** - Use Effect.cached
3. **Set appropriate TTLs** - Balance freshness vs performance
4. **Use Request deduplication** - Automatic with Effect.request
5. **Batch at API boundaries** - Group related requests

## Additional Resources

For comprehensive batching and caching documentation, consult `${CLAUDE_PLUGIN_ROOT}/references/llms-full.txt`.

Search for these sections:
- "Batching" for complete batching guide
- "Caching" for caching patterns
- "Cache" for Cache service
- "Caching Effects" for Effect.cached patterns

Overview

This skill explains how Effect optimizes API calls through batching, caching, and deduplication. It shows how to define request types and resolvers, enable automatic batching, and use built-in caching utilities like Effect.cached and the Cache service. The goal is to eliminate N+1 request patterns and reduce redundant network or database work.

How this skill works

Effect inspects request operations and can aggregate many individual requests into a single batched resolver call, allowing one network/database call to satisfy multiple logical requests. It also memoizes effect results and request outcomes within a query context, preventing duplicate concurrent work and enabling TTL-based or manual invalidation. You can opt in or out of batching and provide custom caches or resolvers with access to contextual services.

When to use it

  • When facing N+1 query problems across loops or forEach operations
  • When multiple callers request the same resource concurrently and you want deduplication
  • When API or DB supports batch endpoints to reduce round trips
  • When you need short-term memoization of expensive computations or network calls
  • When you want fine-grained control over cache TTL, capacity, or invalidation

Best practices

  • Use batched RequestResolvers at API boundaries to group related IDs into a single call
  • Prefer Effect.cached or cachedWithTTL for inexpensive, read-heavy values to reduce repeated fetches
  • Set TTLs to balance freshness and performance; use invalidate hooks for known changes
  • Rely on automatic request deduplication within a query context to avoid duplicate inflight work
  • Disable batching or request caching selectively for operations that must run independently

Example use cases

  • Batching user lookups when iterating todos to avoid N+1 requests to /api/users
  • Using RequestResolver.makeBatched to POST an array of ids and distribute results to individual requests
  • Caching application config with Effect.cached so subsequent reads are instantaneous
  • CachedWithTTL for current user data with automatic expiry after a short interval
  • Creating a Cache service to control capacity, TTL, manual invalidation, and statistics

FAQ

Will batching change the order or semantics of individual requests?

Batched resolvers map results back to each original request, preserving per-request success or failure semantics. Ensure your batch endpoint returns results in a stable mapping or include identifiers in responses.

How do I force fresh data despite caching?

Use cachedInvalidateWithTTL to manually invalidate, or create/attach a custom cache and call invalidate. You can also disable request caching with Effect.withRequestCaching(false).