Skip to main content
Middleware wraps around your tasks and resources, adding cross-cutting concerns like caching, retry logic, timeouts, and more—without cluttering your business logic.

What is Middleware?

Middleware is a function that intercepts task or resource execution, allowing you to:
  • Add behavior before execution (validation, logging)
  • Modify or transform inputs
  • Add behavior after execution (caching, cleanup)
  • Handle errors (retry, circuit breaking)
  • Short-circuit execution (caching, rate limiting)
Think of middleware as layers wrapped around your core logic, forming an “onion” where each layer can add functionality.

How Middleware Works

Middleware runs in the order you define it, wrapping your task or resource:
import { r } from "@bluelibs/runner";

const myTask = r
  .task("app.tasks.example")
  .middleware([
    loggingMiddleware,    // Runs first (outer layer)
    authMiddleware,       // Runs second
    cachingMiddleware,    // Runs third (inner layer)
  ])
  .run(async (input) => {
    // Your business logic runs last
    return processData(input);
  })
  .build();
Execution flow:
  1. loggingMiddleware starts → logs “starting”
  2. authMiddleware starts → checks permissions
  3. cachingMiddleware starts → checks cache
  4. Your task runs → processes data
  5. cachingMiddleware ends → stores in cache
  6. authMiddleware ends
  7. loggingMiddleware ends → logs “completed”

Built-in Middleware

Runner provides production-ready middleware out of the box:

Retry

Automatically retry failed operations with exponential backoff

Timeout

Prevent operations from hanging indefinitely

Circuit Breaker

Fail fast when downstream services are unavailable

Cache

Cache expensive operations with automatic invalidation

Rate Limit

Limit execution frequency to prevent overload

Custom

Create your own middleware for specialized needs

Additional Built-in Middleware

Runner also includes specialized middleware for advanced use cases:
  • Concurrency (globals.middleware.task.concurrency) - Limit concurrent executions with semaphores
  • Fallback (globals.middleware.task.fallback) - Provide backup value or task if primary fails
  • Debounce (globals.middleware.task.debounce) - Delay execution until quiet period ends
  • Throttle (globals.middleware.task.throttle) - Ensure execution at most once per time window
  • Require Context (globals.middleware.task.requireContext) - Enforce async context availability
See Custom Middleware for examples and the API Reference for full configuration details.

Quick Example

Here’s how to add retry and timeout to an API call:
import { r, globals } from "@bluelibs/runner";

const callExternalAPI = r
  .task("api.external")
  .middleware([
    globals.middleware.task.retry.with({ retries: 3 }),
    globals.middleware.task.timeout.with({ ttl: 5000 }), // 5 second timeout
  ])
  .run(async (url: string) => {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  })
  .build();
This task will:
  • Retry up to 3 times on failure
  • Timeout after 5 seconds
  • Use exponential backoff between retries

Task vs Resource Middleware

Middleware comes in two flavors:

Task Middleware

Wraps task execution. Most built-in middleware (retry, cache, timeout) are task middleware.
const taskMiddleware = r.middleware
  .task("app.middleware.logging")
  .run(async ({ task, next, journal }, deps, config) => {
    console.log(`Task ${task.definition.id} starting`);
    const result = await next(task.input);
    console.log(`Task ${task.definition.id} completed`);
    return result;
  })
  .build();

Resource Middleware

Wraps resource initialization. Useful for lifecycle management and configuration.
const resourceMiddleware = r.middleware
  .resource("app.middleware.monitoring")
  .run(async ({ resource, next }, deps, config) => {
    console.log(`Initializing ${resource.definition.id}`);
    const value = await next(resource.config);
    console.log(`Initialized ${resource.definition.id}`);
    return value;
  })
  .build();

Global Middleware

Apply middleware to all tasks automatically:
import { r, globals } from "@bluelibs/runner";

const loggingMiddleware = r.middleware
  .task("app.middleware.logging")
  .everywhere(() => true) // Apply to all tasks
  .dependencies({ logger: globals.resources.logger })
  .run(async ({ task, next }, { logger }) => {
    await logger.info(`Executing: ${task.definition.id}`);
    const result = await next(task.input);
    await logger.info(`Completed: ${task.definition.id}`);
    return result;
  })
  .build();

const app = r
  .resource("app")
  .register([loggingMiddleware]) // Automatically applies to all tasks
  .build();
Global middleware can depend on resources or tasks. Any such dependencies are excluded from the middleware’s execution to prevent infinite loops.

The Execution Journal

Middleware can share state using the execution journal—a type-safe registry that travels with each execution:
import { r, journal } from "@bluelibs/runner";

// Define a typed key
const traceIdKey = journal.createKey<string>("app.traceId");

const traceMiddleware = r.middleware
  .task("app.middleware.trace")
  .run(async ({ task, next, journal }) => {
    // Store trace ID for other middleware/tasks
    journal.set(traceIdKey, `trace-${Date.now()}`);
    return next(task.input);
  })
  .build();

const myTask = r
  .task("app.tasks.example")
  .middleware([traceMiddleware])
  .run(async (input, deps, { journal }) => {
    // Read trace ID set by middleware
    const traceId = journal.get(traceIdKey); // Fully typed!
    console.log("Trace ID:", traceId);
    return { success: true };
  })
  .build();
By default, journal.set() throws if a key already exists. Use { override: true } to intentionally update a value.

Middleware Configuration

Middleware can accept typed configuration:
type AuthConfig = { requiredRole: string };

const authMiddleware = r.middleware
  .task("app.middleware.auth")
  .run(async ({ task, next }, deps, config: AuthConfig) => {
    if (task.input.user.role !== config.requiredRole) {
      throw new Error("Insufficient permissions");
    }
    return next(task.input);
  })
  .build();

// Apply with configuration
const adminTask = r
  .task("app.tasks.adminOnly")
  .middleware([authMiddleware.with({ requiredRole: "admin" })])
  .run(async (input) => "Secret admin data")
  .build();

Best Practices

Middleware runs in the order you define. Place logging/tracing first, caching last:
.middleware([
  loggingMiddleware,  // First: logs everything
  authMiddleware,     // Second: checks permissions
  cacheMiddleware,    // Last: serves from cache if available
])
Export journal keys so middleware can coordinate:
export const journalKeys = {
  userId: journal.createKey<string>("auth.userId"),
};
Each middleware should do one thing well. Combine multiple middleware instead of creating complex ones.
Middleware can catch and transform errors:
try {
  return await next(task.input);
} catch (error) {
  // Log, transform, or rethrow
  throw new CustomError("Operation failed", error);
}

Next Steps

Retry Middleware

Handle transient failures automatically

Cache Middleware

Speed up expensive operations

Custom Middleware

Build your own middleware

Circuit Breaker

Protect against cascading failures

Build docs developers (and LLMs) love