Skip to main content
doc0blade47/honodocumentation home
Sign in

ETag and Cache

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Hono provides robust ETag and Cache middleware features that optimize HTTP request-response lifecycles through automated cryptographic hashing, conditional validation, and Web Standard Cache API integration. By computing SHA-1 or custom digests from response streams, the ETag middleware evaluates If-None-Match headers against strong or weak tags to short-circuit redundant transmissions with 304 Not Modified responses while retaining specified headers. Concurrently, the cache middleware interfaces with runtime storage via caches.open and cache.match, enforcing status code constraints, vary directive checks, and Cache-Control directives to manage cache population and conditional execution.

Sources: src/middleware/etag/index.ts:21-36, src/middleware/etag/index.ts:79-123, src/middleware/cache/index.ts:13-35, src/middleware/cache/index.ts:64-180

ETag generation and hashing algorithms

Public API and Generator Initialization

The ETag middleware accepts an ETagOptions configuration object defining parameters that control tag formatting, retained headers on validation bypass, and custom hashing logic. When no custom generator is provided via options.generateDigest, the initializer falls back to the native crypto.subtle API using the SHA-1 algorithm.

OptionTypeDefaultPurpose
retainedHeadersstring[]RETAINED_304_HEADERSHeaders preserved on 304 Not Modified responses.
weakbooleanfalsePrepends W/ to generated ETags for weak validation.
generateDigest(body: Uint8Array) => ArrayBuffer | Promise<ArrayBuffer>crypto.subtle (SHA-1)Custom digest generation function for response bodies.

Sources: src/middleware/etag/index.ts:9-13, src/middleware/etag/index.ts:38-54, src/middleware/etag/index.ts:79-82

Digest Calculation and Hex Encoding

The generateDigest function reads response bodies iteratively via a ReadableStream reader, merging incoming chunks and invoking the digest generator. Once the stream is fully consumed, the resulting ArrayBuffer digest is converted into a lowercase hexadecimal string by mapping each byte to a zero-padded two-character hex value.

export const generateDigest = async (
  stream: ReadableStream<Uint8Array<ArrayBuffer>> | null,
  generator: (body: Uint8Array<ArrayBuffer>) => ArrayBuffer | Promise<ArrayBuffer>
): Promise<string | null> => {
  if (!stream) {
    return null
  }
 
  let result: ArrayBuffer | undefined = undefined
 
  const reader = stream.getReader()
  for (;;) {
    const { value, done } = await reader.read()
    if (done) {
      break
    }
 
    result = await generator(mergeBuffers(result, value))
  }
 
  if (!result) {
    return null
  }
 
  return Array.prototype.map
    .call(new Uint8Array(result), (x) => x.toString(16).padStart(2, '0'))
    .join('')
}

Sources: src/middleware/etag/digest.ts:16-43

Note

If a response carries an existing ETag header from upstream handlers, the ETag middleware bypasses automatic digest calculation entirely and uses the pre-existing tag value.

Sources: src/middleware/etag/index.ts:90-95

Conditional validation and weak tags

Execution Walkthrough and Sequence Analysis

Conditional validation in the ETag middleware evaluates incoming If-None-Match request headers against computed or pre-existing entity tags. When a request includes an If-None-Match header, the middleware inspects the value after downstream handlers execute and the response ETag is resolved. The validation check executes through a strict function call chain: etag handler invokes etagMatches, which in turn applies stripWeak to parse and normalize tags.

The execution sequence follows this precise path:

  1. etag — The main middleware function extracts the If-None-Match request header and awaits downstream execution before reading the ETag response header or generating a fresh digest. Sources: src/middleware/etag/index.ts:84-105
  2. etagMatches — Evaluates whether ifNoneMatch is non-null, splits the header value by commas and optional whitespace (/,\s*/), and checks if any token matches the target ETag. Sources: src/middleware/etag/index.ts:32-36
  3. stripWeak — Strips the weak validation prefix W/ from the beginning of a tag string using a regular expression replacement (tag.replace(/^W\//, '')), allowing weak and strong tags to be compared equivalently during validation. Sources: src/middleware/etag/index.ts:30-30

304 Response Generation and Retained Headers

When etagMatches returns true, the middleware short-circuits the response by constructing a new 304 Not Modified response instance. This response discards the response body while preserving specific metadata headers defined by retainedHeaders. By default, the middleware references RETAINED_304_HEADERS to determine which response headers survive the validation bypass.

Header ConstantValue / IdentifierPurpose
RETAINED_304_HEADERS'cache-control'Retains caching directives on 304 responses.
RETAINED_304_HEADERS'content-location'Retains resource location metadata.
RETAINED_304_HEADERS'date'Retains server response timestamp.
RETAINED_304_HEADERS'etag'Retains the entity tag for validation tracking.
RETAINED_304_HEADERS'expires'Retains expiration time limits.
RETAINED_304_HEADERS'vary'Retains cache negotiation criteria.

Sources: src/middleware/etag/index.ts:21-28, src/middleware/etag/index.ts:107-119

Note

When iterating over response headers during a 304 short-circuit, header keys are converted to lowercase via key.toLowerCase() before checking against the retainedHeaders index array, ensuring case-insensitive header preservation.

Sources: src/middleware/etag/index.ts:115-119

Cache middleware and storage adapter

Cache Storage Initialization and Availability Check

The cache middleware integrates with the Web Standard Cache API via globalThis.caches. Upon initialization, the cache function verifies if globalThis.caches is defined in the current runtime. If the storage adapter is unavailable, the middleware checks the onCacheNotAvailable option: when set to false, it silently suppresses logging; when provided as a custom function, it invokes that handler; otherwise, it logs a default message to the console and returns a pass-through middleware handler that immediately invokes next().

    if (!globalThis.caches) {
      if (options.onCacheNotAvailable === false) {
        // suppress log
      } else if (options.onCacheNotAvailable) {
        options.onCacheNotAvailable()
      } else {
        console.log('Cache Middleware is not enabled because caches is not defined.')
      }
      return async (_c, next) => await next()
    }

Sources: src/middleware/cache/index.ts:73-82

When globalThis.caches is present, the middleware resolves the cache store name. The cacheName option accepts either a static string or a dynamic function (c: Context) => Promise<string> | string. The resolved store name is passed to caches.open(cacheName) to obtain the cache instance.

    const cacheName =
      typeof options.cacheName === 'function' ? await options.cacheName(c) : options.cacheName
    const cache = await caches.open(cacheName)

Sources: src/middleware/cache/index.ts:155-157

Request Matching and Key Generation Lifecycle

Before executing downstream handlers, the middleware intercepts incoming requests. Requests are filtered by method and authorization headers: non-GET requests or requests containing an Authorization header bypass the cache middleware entirely and proceed directly to next().

    if (c.req.method !== 'GET' || c.req.raw.headers.has('Authorization')) {
      await next()
      return
    }

Sources: src/middleware/cache/index.ts:139-142

For valid GET requests, the cache key is constructed starting from the request URL (c.req.url). If a custom keyGenerator option is supplied, it executes to replace the base key string. When vary directives are configured, the middleware appends each specified header name and its URI-encoded request header value to the cache key using a double-colon delimiter format: ::directive=encodedValue.

    let key = c.req.url
    if (options.keyGenerator) {
      key = await options.keyGenerator(c)
    }
    if (varyDirectives) {
      for (const directive of varyDirectives) {
        const value = c.req.raw.headers.get(directive) ?? ''
        key += `::${directive}=${encodeURIComponent(value)}`
      }
    }

Sources: src/middleware/cache/index.ts:144-153

Once the cache key is assembled, the middleware queries the storage adapter via cache.match(key). If a matching response is located, a new Response is instantiated from the cached response body and headers, short-circuiting further request execution and returning immediately.

    const response = await cache.match(key)
    if (response) {
      return new Response(response.body, response)
    }

Sources: src/middleware/cache/index.ts:158-161

Warning

Requests bearing an Authorization header automatically bypass the cache lookup and storage lifecycle, preventing authenticated user data from being improperly stored in shared cache tiers.

Sources: src/middleware/cache/index.ts:139-142

Writing to the Cache Store

When a cache miss occurs, the middleware awaits downstream execution (await next()). It then verifies whether the resulting response status code is present in the allowed cacheableStatusCodes set (which defaults to [200]). If the status code is ineligible, the middleware returns without caching.

    await next()
    if (!cacheableStatusCodes.has(c.res.status)) {
      return
    }

Sources: src/middleware/cache/index.ts:163-166

Eligible responses undergo header augmentation and skip-cache evaluations. If checks pass, the response is cloned via c.res.clone(). The write operation to the cache store is governed by the wait boolean option:

  • When wait is set to true, Hono awaits the resolution of cache.put(key, res) before finishing the middleware handler execution (a requirement for runtimes like Deno).
  • When wait is false (the default), the promise returned by cache.put(key, res) is passed to c.executionCtx.waitUntil() to allow background storage without blocking the response delivery.
    const res = c.res.clone()
    if (options.wait) {
      await cache.put(key, res)
    } else {
      c.executionCtx.waitUntil(cache.put(key, res))
    }

Sources: src/middleware/cache/index.ts:174-179

Note

The wait option defaults to false. Explicitly setting wait: true is mandatory in Deno and other runtime environments that require pending promises to resolve within the request scope before termination.

Sources: src/middleware/cache/index.ts:44-44, src/middleware/cache/index.ts:84-86

Cache control and skipping heuristics

Heuristics and Skip Inspection

The cache middleware evaluates several inspection rules before deciding whether an eligible response should be stored in the cache. These heuristics examine the Cache-Control header, Set-Cookie headers, and Vary directives to prevent writing private or sensitive data.

const shouldSkipCacheControl = (cacheControl: string | null): boolean =>
  !!cacheControl && /(?:^|,\s*)(?:private|no-(?:store|cache))(?:\s*(?:=|,|$))/i.test(cacheControl)

Sources: src/middleware/cache/index.ts:15-16

The shouldSkipCache function consolidates these checks, returning true if any skipping criterion is satisfied:

const shouldSkipCache = (
  res: Response,
  optionsVaryDirectives: Set<string> | undefined,
  responseVary: string[]
): boolean =>
  (responseVary.length &&
    (!optionsVaryDirectives || responseVary.some((name) => !optionsVaryDirectives.has(name)))) ||
  shouldSkipCacheControl(res.headers.get('Cache-Control')) ||
  res.headers.has('Set-Cookie')

Sources: src/middleware/cache/index.ts:27-35

Warning

Responses containing a Set-Cookie header or a Cache-Control directive matching private, no-store, or no-cache are automatically excluded from cache writes to prevent leaking user sessions or private records. Sources: src/middleware/cache/index.ts:32-35

Skip Inspection Call Chain

When determining whether to skip caching a response after downstream execution, Hono invokes a dedicated evaluation sequence.

Sources: src/middleware/cache/index.ts:15-17, src/middleware/cache/index.ts:27-35, src/middleware/cache/index.ts:170-172

  1. cache invokes shouldSkipCache passing the response and vary directives.
  2. shouldSkipCache checks vary conditions and calls shouldSkipCacheControl.
  3. shouldSkipCacheControl tests the Cache-Control header string against the regex pattern for private, no-store, or no-cache.

Sources: src/middleware/cache/index.ts:15-17, src/middleware/cache/index.ts:27-35, src/middleware/cache/index.ts:170-172

Header Augmentation Options

The middleware can inject configured Cache-Control and Vary headers into outgoing responses prior to storage evaluation via the internal addHeader function.

  const addHeader = (c: Context, responseVary: string[]) => {
    if (cacheControlDirectives) {
      const existingDirectives =
        c.res.headers
          .get('Cache-Control')
          ?.split(',')
          .map((d) => d.trim().split('=', 1)[0]) ?? []
      for (const directive of cacheControlDirectives) {
        let [name, value] = directive.trim().split('=', 2)
        name = name.toLowerCase()
        if (!existingDirectives.includes(name)) {
          c.header('Cache-Control', `${name}${value ? `=${value}` : ''}`, { append: true })
        }
      }
    }
 
    if (varyDirectives) {
      if (responseVary.length === 0) {
        c.header('Vary', Array.from(varyDirectives).join(', '))
      } else {
        const merged = new Set(varyDirectives)
        for (const directive of responseVary) {
          merged.add(directive)
        }
        if (merged.has('*')) {
          c.header('Vary', '*')
        } else {
          c.header('Vary', Array.from(merged).join(', '))
        }
      }
    }
  }

Sources: src/middleware/cache/index.ts:105-136

Note

If varyDirectives includes the wildcard token *, the middleware throws an error upon initialization because RFC 7231 Section 7.1.4 disallows effective caching when * is specified in the Vary header. Sources: src/middleware/cache/index.ts:93-99

Buffer management for digest hashing

Chunk Concatenation and Memory Merging

The generateDigest utility in src/middleware/etag/digest.ts processes streamed response payloads by reading chunks iteratively from a ReadableStream<Uint8Array<ArrayBuffer>>. When building the complete hash input across multiple chunks, the internal mergeBuffers function combines an existing ArrayBuffer with an incoming Uint8Array<ArrayBuffer> chunk. If buffer1 is undefined, it returns buffer2 directly; otherwise, it allocates a new ArrayBuffer matching the combined byte lengths, writes the initial buffer at offset 0, and copies the incoming chunk at offset buffer1.byteLength.

const mergeBuffers = (
  buffer1: ArrayBuffer | undefined,
  buffer2: Uint8Array<ArrayBuffer>
): Uint8Array<ArrayBuffer> => {
  if (!buffer1) {
    return buffer2
  }
  const merged = new Uint8Array<ArrayBuffer>(
    new ArrayBuffer(buffer1.byteLength + buffer2.byteLength)
  )
  merged.set(new Uint8Array(buffer1), 0)
  merged.set(buffer2, buffer1.byteLength)
  return merged
}

Sources: src/middleware/etag/digest.ts:1-14

Note

Successive chunks from the stream reader are accumulated iteratively via mergeBuffers, feeding the result into the custom or default generator function on every read cycle until the stream signals completion. Sources: src/middleware/etag/digest.ts:26-34

Stream Reader Loop and Hex Encoding

Once all stream chunks are processed and hashed, the resulting ArrayBuffer is converted into a lowercase hexadecimal string by mapping over its bytes with toString(16).padStart(2, '0') and joining them. If the input stream is null or yields no result, the generator returns null.

export const generateDigest = async (
  stream: ReadableStream<Uint8Array<ArrayBuffer>> | null,
  generator: (body: Uint8Array<ArrayBuffer>) => ArrayBuffer | Promise<ArrayBuffer>
): Promise<string | null> => {
  if (!stream) {
    return null
  }
 
  let result: ArrayBuffer | undefined = undefined
 
  const reader = stream.getReader()
  for (;;) {
    const { value, done } = await reader.read()
    if (done) {
      break
    }
 
    result = await generator(mergeBuffers(result, value))
  }
 
  if (!result) {
    return null
  }
 
  return Array.prototype.map
    .call(new Uint8Array(result), (x) => x.toString(16).padStart(2, '0'))
    .join('')
}

Sources: src/middleware/etag/digest.ts:16-43

Was this page helpful?