diff --git a/index.js b/index.js
index 444706560ae..be2269ad90e 100644
--- a/index.js
+++ b/index.js
@@ -39,7 +39,12 @@ module.exports.RedirectHandler = RedirectHandler
 module.exports.interceptors = {
   redirect: require('./lib/interceptor/redirect'),
   retry: require('./lib/interceptor/retry'),
-  dump: require('./lib/interceptor/dump')
+  dump: require('./lib/interceptor/dump'),
+  cache: require('./lib/interceptor/cache')
+}
+
+module.exports.cacheStores = {
+  MemoryCacheStore: require('./lib/cache/memory-cache-store')
 }
 
 module.exports.buildConnector = buildConnector
diff --git a/lib/cache/memory-cache-store.js b/lib/cache/memory-cache-store.js
new file mode 100644
index 00000000000..c43ae358f3c
--- /dev/null
+++ b/lib/cache/memory-cache-store.js
@@ -0,0 +1,101 @@
+'use strict'
+
+/**
+ * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
+ * @implements {CacheStore}
+ */
+class MemoryCacheStore {
+  /**
+   * @type {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts} opts
+   */
+  #opts = {}
+  /**
+   * @type {Map<string, import('../../types/cache-interceptor.d.ts').default.CacheStoreValue[]>}
+   */
+  #data = new Map()
+
+  /**
+   * @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} opts
+   */
+  constructor (opts) {
+    this.#opts = opts ?? {}
+
+    if (!this.#opts.maxEntrySize) {
+      this.#opts.maxEntrySize = Infinity
+    }
+  }
+
+  get maxEntrySize () {
+    return this.#opts.maxEntrySize
+  }
+
+  /**
+   * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
+   * @returns {Promise<import('../../types/cache-interceptor.d.ts').default.CacheStoreValue | undefined>}
+   */
+  get (req) {
+    const key = this.#makeKey(req)
+
+    const values = this.#data.get(key)
+    if (!values) {
+      return
+    }
+
+    let value
+    const now = Date.now()
+    for (let i = values.length - 1; i >= 0; i--) {
+      const current = values[i]
+      if (now >= current.deleteAt) {
+        // Delete the expired ones
+        values.splice(i, 1)
+        continue
+      }
+
+      let matches = true
+
+      if (current.vary) {
+        for (const key in current.vary) {
+          if (current.vary[key] !== req.headers[key]) {
+            matches = false
+            break
+          }
+        }
+      }
+
+      if (matches) {
+        value = current
+        break
+      }
+    }
+
+    return value
+  }
+
+  /**
+   * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
+   * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} value
+   */
+  put (req, value) {
+    const key = this.#makeKey(req)
+
+    let values = this.#data.get(key)
+    if (!values) {
+      values = []
+      this.#data.set(key, values)
+    }
+
+    values.push(value)
+  }
+
+  /**
+   * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
+   * @returns {string}
+   */
+  #makeKey (req) {
+    // TODO origin is undefined
+    // https://www.rfc-editor.org/rfc/rfc9111.html#section-2-3
+    return `${req.origin}:${req.path}:${req.method}`
+  }
+}
+
+module.exports = MemoryCacheStore
diff --git a/lib/handler/cache-handler.js b/lib/handler/cache-handler.js
new file mode 100644
index 00000000000..9ea2e41666c
--- /dev/null
+++ b/lib/handler/cache-handler.js
@@ -0,0 +1,353 @@
+'use strict'
+
+const util = require('../core/util')
+const DecoratorHandler = require('../handler/decorator-handler')
+const { parseCacheControlHeader, parseVaryHeader } = require('../util/cache')
+
+class CacheHandler extends DecoratorHandler {
+  /**
+   * @type {import('../../types/cache-interceptor.d.ts').default.CacheOptions}
+   */
+  #opts = null
+  /**
+   * @type {import('../../types/dispatcher.d.ts').default.RequestOptions}
+   */
+  #req = null
+  /**
+   * @type {import('../../types/dispatcher.d.ts').default.DispatchHandlers}
+   */
+  #handler = null
+  /**
+   * @type {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue | undefined}
+   */
+  #value = null
+
+  /**
+   * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} opts
+   * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
+   * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
+   */
+  constructor (opts, req, handler) {
+    super(handler)
+
+    this.#opts = opts
+    this.#req = req
+    this.#handler = handler
+  }
+
+  /**
+   * @see {DispatchHandlers.onHeaders}
+   *
+   * @param {number} statusCode
+   * @param {Buffer[]} rawHeaders
+   * @param {() => void} resume
+   * @param {string} statusMessage
+   * @param {string[] | undefined} headers
+   * @returns
+   */
+  onHeaders (
+    statusCode,
+    rawHeaders,
+    resume,
+    statusMessage,
+    headers = util.parseHeaders(rawHeaders)
+  ) {
+    const cacheControlHeader = headers['cache-control']
+    const contentLengthHeader = headers['content-length']
+
+    if (!cacheControlHeader || !contentLengthHeader) {
+      // Don't have the headers we need, can't cache
+      return this.#handler.onHeaders(
+        statusCode,
+        rawHeaders,
+        resume,
+        statusMessage,
+        headers
+      )
+    }
+
+    const maxEntrySize = this.#getMaxEntrySize()
+    const contentLength = Number(headers['content-length'])
+    const currentSize =
+      this.#getSizeOfBuffers(rawHeaders) + (statusMessage?.length ?? 0) + 64
+    if (
+      isNaN(contentLength) ||
+      contentLength > maxEntrySize ||
+      currentSize > maxEntrySize
+    ) {
+      return this.#handler.onHeaders(
+        statusCode,
+        rawHeaders,
+        resume,
+        statusMessage,
+        headers
+      )
+    }
+
+    const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader)
+    if (!canCacheResponse(statusCode, headers, cacheControlDirectives)) {
+      return this.#handler.onHeaders(
+        statusCode,
+        rawHeaders,
+        resume,
+        statusMessage,
+        headers
+      )
+    }
+
+    const now = Date.now()
+    const staleAt = determineStaleAt(headers, cacheControlDirectives)
+    if (staleAt) {
+      const varyDirectives = headers.vary
+        ? parseVaryHeader(headers.vary, this.#req.headers)
+        : undefined
+      const deleteAt = determineDeleteAt(cacheControlDirectives, staleAt)
+
+      const strippedHeaders = stripNecessaryHeaders(
+        rawHeaders,
+        headers,
+        cacheControlDirectives
+      )
+
+      this.#value = {
+        complete: false,
+        statusCode,
+        statusMessage,
+        rawHeaders: strippedHeaders,
+        body: [],
+        vary: varyDirectives,
+        size: currentSize,
+        cachedAt: now,
+        staleAt: now + staleAt,
+        deleteAt: now + deleteAt
+      }
+    }
+
+    return this.#handler.onHeaders(
+      statusCode,
+      rawHeaders,
+      resume,
+      statusMessage,
+      headers
+    )
+  }
+
+  /**
+   * @see {DispatchHandlers.onData}
+   *
+   * @param {Buffer} chunk
+   * @returns {boolean}
+   */
+  onData (chunk) {
+    if (this.#value) {
+      this.#value.size += chunk.length
+
+      if (this.#value.size > this.#getMaxEntrySize()) {
+        this.#value = null
+      } else {
+        this.#value.body.push(chunk)
+      }
+    }
+
+    return this.#handler.onData(chunk)
+  }
+
+  /**
+   * @see {DispatchHandlers.onComplete}
+   *
+   * @param {string[] | null} rawTrailers
+   */
+  onComplete (rawTrailers) {
+    if (this.#value) {
+      this.#value.complete = true
+      this.#value.rawTrailers = rawTrailers
+      this.#value.size += this.#getSizeOfBuffers(rawTrailers)
+
+      // If we're still under the max entry size, let's add it to the cache
+      if (this.#getMaxEntrySize() > this.#value.size) {
+        const result = this.#opts.store.put(this.#req, this.#value)
+        if (result && result.constructor.name === 'Promise') {
+          result.catch(err => {
+            throw err
+          })
+        }
+      }
+    }
+
+    return this.#handler.onComplete(rawTrailers)
+  }
+
+  /**
+   * @see {DispatchHandlers.onError}
+   *
+   * @param {Error} err
+   */
+  onError (err) {
+    this.#value = undefined
+    this.#handler.onError(err)
+  }
+
+  /**
+   * @returns {number}
+   */
+  #getMaxEntrySize () {
+    return this.#opts.store.maxEntrySize ?? Infinity
+  }
+
+  /**
+   * @param {string[] | Buffer[]} arr
+   * @returns {number}
+   */
+  #getSizeOfBuffers (arr) {
+    let size = 0
+
+    for (const buffer of arr) {
+      size += buffer.length
+    }
+
+    return size
+  }
+}
+
+/**
+ * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
+ *
+ * @param {number} statusCode
+ * @param {Record<string, string>} headers
+ * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
+ */
+function canCacheResponse (statusCode, headers, cacheControlDirectives) {
+  if (![200, 307].includes(statusCode)) {
+    return false
+  }
+
+  if (
+    !cacheControlDirectives.public &&
+    !cacheControlDirectives['s-maxage'] &&
+    !cacheControlDirectives['must-revalidate']
+  ) {
+    // Response can't be used in a shared cache
+    return false
+  }
+
+  if (
+    // TODO double check these
+    cacheControlDirectives.private === true ||
+    cacheControlDirectives['no-cache'] === true ||
+    cacheControlDirectives['no-store'] ||
+    cacheControlDirectives['no-transform'] ||
+    cacheControlDirectives['must-understand'] ||
+    cacheControlDirectives['proxy-revalidate']
+  ) {
+    return false
+  }
+
+  // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5
+  if (headers.vary === '*') {
+    return false
+  }
+
+  // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
+  if (headers['authorization']) {
+    if (
+      Array.isArray(cacheControlDirectives['no-cache']) &&
+      cacheControlDirectives['no-cache'].includes('authorization')
+    ) {
+      return false
+    }
+
+    if (
+      Array.isArray(cacheControlDirectives['private']) &&
+      cacheControlDirectives['private'].includes('authorization')
+    ) {
+      return false
+    }
+  }
+
+  return true
+}
+
+/**
+ * @param {Record<string, string>} headers
+ * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
+ *
+ * @returns {number | undefined} time that the value is stale at or undefined if it shouldn't be cached
+ */
+function determineStaleAt (headers, cacheControlDirectives) {
+  // Prioritize s-maxage since we're a shared cache
+  //  s-maxage > max-age > Expire
+  //  https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
+  const sMaxAge = cacheControlDirectives['s-maxage']
+  if (sMaxAge) {
+    return sMaxAge * 1000
+  }
+
+  if (cacheControlDirectives.immutable) {
+    // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
+    return 31536000
+  }
+
+  const maxAge = cacheControlDirectives['max-age']
+  if (maxAge) {
+    return maxAge * 1000
+  }
+
+  if (headers.expire) {
+    // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
+    return new Date() - new Date(headers.expire)
+  }
+
+  return undefined
+}
+
+/**
+ * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
+ * @param {number} staleAt
+ */
+function determineDeleteAt (cacheControlDirectives, staleAt) {
+  if (cacheControlDirectives['stale-while-revalidate']) {
+    return (cacheControlDirectives['stale-while-revalidate'] * 1000)
+  }
+
+  return staleAt
+}
+
+/**
+ * Strips headers required to be removed in cached responses
+ * @param {Buffer[]} rawHeaders
+ * @param {string[]} parsedHeaders
+ * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
+ * @returns {Buffer[]}
+ */
+function stripNecessaryHeaders (rawHeaders, parsedHeaders, cacheControlDirectives) {
+  const headersToRemove = ['connection']
+
+  if (Array.isArray(cacheControlDirectives['no-cache'])) {
+    headersToRemove.push(...cacheControlDirectives['no-cache'])
+  }
+
+  if (Array.isArray(cacheControlDirectives['private'])) {
+    headersToRemove.push(...cacheControlDirectives['private'])
+  }
+
+  let strippedRawHeaders
+  for (let i = 0; i < parsedHeaders.length; i++) {
+    const header = parsedHeaders[i]
+    const kvDelimiterIndex = header.indexOf(':')
+    const headerName = header.substring(0, kvDelimiterIndex)
+
+    if (headerName in headersToRemove) {
+      if (!strippedRawHeaders) {
+        strippedRawHeaders = rawHeaders.slice(0, i - 1)
+      } else {
+        strippedRawHeaders.push(rawHeaders[i])
+      }
+    }
+  }
+
+  strippedRawHeaders ??= rawHeaders
+
+  return strippedRawHeaders
+}
+
+module.exports = CacheHandler
diff --git a/lib/handler/cache-revalidation-handler.js b/lib/handler/cache-revalidation-handler.js
new file mode 100644
index 00000000000..5ea70c20070
--- /dev/null
+++ b/lib/handler/cache-revalidation-handler.js
@@ -0,0 +1,105 @@
+'use strict'
+
+const DecoratorHandler = require('../handler/decorator-handler')
+
+/**
+ * This takes care of revalidation requests we send to the origin. If we get
+ *  a response indicating that what we have is cached (via a HTTP 304), we can
+ *  continue using the cached value. Otherwise, we'll receive the new response
+ *  here, which we then just pass on to the next handler (most likely a
+ *  CacheHandler). Note that this assumes the proper headers were already
+ *  included in the request to tell the origin that we want to revalidate the
+ *  response (i.e. if-modified-since).
+ *
+ * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-validation
+ *
+ * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandlers} DispatchHandlers
+ * @implements {DispatchHandlers}
+ */
+class CacheRevalidationHandler extends DecoratorHandler {
+  #successful = false
+  /**
+   * @type {() => void}
+   */
+  #successCallback = null
+  /**
+   * @type {import('../../types/dispatcher.d.ts').default.DispatchHandlers}
+   */
+  #handler = null
+
+  /**
+   * @param {() => void} successCallback Function to call if the cached value is valid
+   * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
+   */
+  constructor (successCallback, handler) {
+    super(handler)
+
+    this.#successCallback = successCallback
+    this.#handler = handler
+  }
+
+  /**
+   * @see {DispatchHandlers.onHeaders}
+   *
+   * @param {number} statusCode
+   * @param {Buffer[]} rawHeaders
+   * @param {() => void} resume
+   * @param {string} statusMessage
+   * @param {string[] | undefined} headers
+   * @returns {boolean}
+   */
+  onHeaders (
+    statusCode,
+    rawHeaders,
+    resume,
+    statusMessage,
+    headers = undefined
+  ) {
+    // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-a-validation-respo
+    if (statusCode === 304) {
+      this.#successful = true
+      this.#successCallback()
+      return true
+    }
+
+    return this.#handler.onHeaders(
+      statusCode,
+      rawHeaders,
+      resume,
+      statusMessage,
+      headers
+    )
+  }
+
+  /**
+   * @see {DispatchHandlers.onData}
+   *
+   * @param {Buffer} chunk
+   * @returns {boolean}
+   */
+  onData (chunk) {
+    return this.#successful ? true : this.#handler.onData(chunk)
+  }
+
+  /**
+   * @see {DispatchHandlers.onComplete}
+   *
+   * @param {string[] | null} rawTrailers
+   */
+  onComplete (rawTrailers) {
+    if (!this.#successful) {
+      this.#handler.onComplete(rawTrailers)
+    }
+  }
+
+  /**
+   * @see {DispatchHandlers.onError}
+   *
+   * @param {Error} err
+   */
+  onError (err) {
+    this.#handler.onError(err)
+  }
+}
+
+module.exports = CacheRevalidationHandler
diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js
new file mode 100644
index 00000000000..7967f2aa981
--- /dev/null
+++ b/lib/interceptor/cache.js
@@ -0,0 +1,158 @@
+'use strict'
+
+const CacheHandler = require('../handler/cache-handler')
+const MemoryCacheStore = require('../cache/memory-cache-store')
+const CacheRevalidationHandler = require('../handler/cache-revalidation-handler')
+
+/**
+ * Gives the downstream handler the request's cached response or dispatches
+ *  it if it isn't cached
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} globalOpts
+ * @param {*} dispatch TODO type
+ * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue | undefined} value
+ */
+function handleCachedResult (
+  globalOpts,
+  dispatch,
+  opts,
+  handler,
+  value
+) {
+  if (!value) {
+    // Request isn't cached, let's continue dispatching it
+    dispatch(opts, new CacheHandler(globalOpts, opts, handler))
+    return
+  }
+
+  // Dump body on error
+  opts.body?.on('error', () => {}).resume()
+
+  const respondWithCachedValue = () => {
+    const ac = new AbortController()
+    const signal = ac.signal
+
+    try {
+      handler.onConnect(ac.abort)
+      signal.throwIfAborted()
+
+      // Add the age header
+      // https://www.rfc-editor.org/rfc/rfc9111.html#name-age
+      const age = Math.round((Date.now() - value.cachedAt) / 1000)
+
+      // Copy the headers in case we got this from an in-memory store. We don't
+      //  want to modify the original response.
+      const headers = [...value.rawHeaders]
+      headers.push(Buffer.from('age'), Buffer.from(`${age}`))
+
+      handler.onHeaders(value.statusCode, headers, () => {}, value.statusMessage)
+      signal.throwIfAborted()
+
+      if (opts.method === 'HEAD') {
+        handler.onComplete([])
+      } else {
+        for (const chunk of value.body) {
+          while (!handler.onData(chunk)) {
+            signal.throwIfAborted()
+          }
+        }
+
+        handler.onComplete(value.rawTrailers ?? null)
+      }
+    } catch (err) {
+      handler.onError(err)
+    }
+  }
+
+  // Check if the response is stale
+  const now = Date.now()
+  if (now > value.staleAt) {
+    if (now > value.deleteAt) {
+      // Safety check in case the store gave us a response that should've been
+      //  deleted already
+      dispatch(opts, new CacheHandler(globalOpts, opts, handler))
+      return
+    }
+
+    // Need to revalidate the request
+    opts.headers.push(`if-modified-since: ${new Date(value.cachedAt).toUTCString()}`)
+
+    dispatch(
+      opts,
+      new CacheRevalidationHandler(
+        respondWithCachedValue,
+        new CacheHandler(globalOpts, opts, handler)
+      )
+    )
+
+    return
+  }
+
+  // Response is still fresh, let's return it
+  respondWithCachedValue()
+}
+
+/**
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions | undefined} globalOpts
+ * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor}
+ */
+module.exports = globalOpts => {
+  if (!globalOpts) {
+    globalOpts = {}
+  }
+
+  if (globalOpts.store) {
+    for (const fn of ['get', 'put', 'delete']) {
+      if (typeof globalOpts.store[fn] !== 'function') {
+        throw new Error(`CacheStore needs a \`${fn}()\` function`)
+      }
+    }
+
+    if (!globalOpts.store.maxEntrySize) {
+      throw new Error('CacheStore needs a maxEntrySize getter')
+    }
+
+    if (globalOpts.store.maxEntrySize <= 0) {
+      throw new Error('CacheStore maxEntrySize needs to be >= 1')
+    }
+  } else {
+    globalOpts.store = new MemoryCacheStore()
+  }
+
+  if (!globalOpts.methods) {
+    globalOpts.methods = ['GET']
+  }
+
+  return dispatch => {
+    return (opts, handler) => {
+      if (!globalOpts.methods.includes(opts.method)) {
+        // Not a method we want to cache, skip
+        return dispatch(opts, handler)
+      }
+
+      const result = globalOpts.store.get(opts)
+      if (result && result.constructor.name === 'Promise') {
+        result.then(value => {
+          handleCachedResult(
+            globalOpts,
+            dispatch,
+            opts,
+            handler,
+            value
+          )
+        })
+      } else {
+        handleCachedResult(
+          globalOpts,
+          dispatch,
+          opts,
+          handler,
+          result
+        )
+      }
+
+      return true
+    }
+  }
+}
diff --git a/lib/util/cache.js b/lib/util/cache.js
new file mode 100644
index 00000000000..f6b7d50d09f
--- /dev/null
+++ b/lib/util/cache.js
@@ -0,0 +1,161 @@
+/**
+ * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-cache-control
+ * @see https://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml
+ *
+ * @typedef {{
+ *   'max-stale'?: number;
+ *   'min-fresh'?: number;
+ *   'max-age'?: number;
+ *   's-maxage'?: number;
+ *   'stale-while-revalidate'?: number;
+ *   'stale-if-error'?: number;
+ *   public?: true;
+ *   private?: true;
+ *   'no-store'?: true;
+ *   'no-cache'?: true | string[];
+ *   'must-revalidate'?: true;
+ *   'proxy-revalidate'?: true;
+ *   immutable?: true;
+ *   'no-transform'?: true;
+ *   'must-understand'?: true;
+ *   'only-if-cached'?: true;
+ * }} CacheControlDirectives
+ *
+ * @param {string} header
+ * @returns {CacheControlDirectives}
+ */
+function parseCacheControlHeader (header) {
+  /**
+   * @type {import('../util/cache.js').CacheControlDirectives}
+   */
+  const output = {}
+
+  const directives = header.toLowerCase().split(',')
+  for (let i = 0; i < directives.length; i++) {
+    const directive = directives[i]
+    const keyValueDelimiter = directive.indexOf('=')
+
+    let key
+    let value
+    if (keyValueDelimiter !== -1) {
+      key = directive.substring(0, keyValueDelimiter).trim()
+      value = directive
+        .substring(keyValueDelimiter + 1, directive.length)
+        .trim()
+        .toLowerCase()
+    } else {
+      key = directive.trim()
+    }
+
+    switch (key) {
+      case 'min-fresh':
+      case 'max-stale':
+      case 'max-age':
+      case 's-maxage':
+      case 'stale-while-revalidate':
+      case 'stale-if-error': {
+        const parsedValue = parseInt(value, 10)
+        if (isNaN(parsedValue)) {
+          continue
+        }
+
+        output[key] = parsedValue
+
+        break
+      }
+      case 'private':
+      case 'no-cache': {
+        if (value) {
+          // The private and no-cache directives can be unqualified (aka just
+          //  `private` or `no-cache`) or qualified (w/ a value). When they're
+          //  qualified, it's a list of headers like `no-cache=header1`,
+          //  `no-cache="header1"`, or `no-cache="header1, header2"`
+          // If we're given multiple headers, the comma messes us up since
+          //  we split the full header by commas. So, let's loop through the
+          //  remaining parts in front of us until we find one that ends in a
+          //  quote. We can then just splice all of the parts in between the
+          //  starting quote and the ending quote out of the directives array
+          //  and continue parsing like normal.
+          // https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2
+          if (value[0] === '"') {
+            // Something like `no-cache="some-header"` OR `no-cache="some-header, another-header"`.
+
+            // Add the first header on and cut off the leading quote
+            const headers = [value.substring(1)]
+
+            let foundEndingQuote = false
+            for (let j = i; j < directives.length; j++) {
+              const nextPart = directives[j]
+              if (nextPart.endsWith('"')) {
+                foundEndingQuote = true
+                headers.push(...directives.splice(i + 1, j - 1).map(header => header.trim()))
+
+                const lastHeader = header[headers.length - 1]
+                headers[headers.length - 1] = lastHeader.substring(0, lastHeader.length - 1)
+                break
+              }
+            }
+
+            if (!foundEndingQuote) {
+              // Something like `no-cache="some-header` with no end quote,
+              //  let's just ignore it
+              continue
+            }
+
+            output[key] = headers
+          } else {
+            // Something like `no-cache=some-header`
+            output[key] = [value]
+          }
+
+          break
+        }
+      }
+      // eslint-disable-next-line no-fallthrough
+      case 'public':
+      case 'no-store':
+      case 'must-revalidate':
+      case 'proxy-revalidate':
+      case 'immutable':
+      case 'no-transform':
+      case 'must-understand':
+      case 'only-if-cached':
+        if (value) {
+          continue
+        }
+
+        output[key] = true
+        break
+      default:
+        // Ignore unknown directives as per https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.3-1
+        continue
+    }
+  }
+
+  return output
+}
+
+/**
+ * @param {string} varyHeader Vary header from the server
+ * @param {Record<string, string>} headers Request headers
+ * @returns {Record<string, string>}
+ */
+function parseVaryHeader (varyHeader, headers) {
+  const output = {}
+
+  const varyingHeaders = varyHeader.toLowerCase().split(',')
+  for (const header of varyingHeaders) {
+    const trimmedHeader = header.trim()
+
+    if (headers[trimmedHeader]) {
+      output[trimmedHeader] = headers[trimmedHeader]
+    }
+  }
+
+  return output
+}
+
+module.exports = {
+  parseCacheControlHeader,
+  parseVaryHeader
+}
diff --git a/test/cache-interceptor/cache-stores.js b/test/cache-interceptor/cache-stores.js
new file mode 100644
index 00000000000..70bb39fff13
--- /dev/null
+++ b/test/cache-interceptor/cache-stores.js
@@ -0,0 +1,167 @@
+const { describe, test } = require('node:test')
+const { deepStrictEqual, strictEqual } = require('node:assert')
+const MemoryCacheStore = require('../../lib/cache/memory-cache-store')
+
+const cacheStoresToTest = [
+  MemoryCacheStore
+]
+
+for (const CacheStore of cacheStoresToTest) {
+  describe(CacheStore.prototype.constructor.name, () => {
+    test('basic functionality', async () => {
+      // Checks that it can store & fetch different responses
+      const store = new CacheStore()
+
+      const request = {
+        origin: 'localhost',
+        path: '/',
+        method: 'GET',
+        headers: {}
+      }
+      const requestValue = {
+        complete: true,
+        statusCode: 200,
+        statusMessage: '',
+        rawHeaders: [1, 2, 3],
+        rawTrailers: [4, 5, 6],
+        body: ['part1', 'part2'],
+        size: 16,
+        cachedAt: Date.now(),
+        staleAt: Date.now() + 10000,
+        deleteAt: Date.now() + 20000
+      }
+
+      // Sanity check
+      strictEqual(await store.get(request), undefined)
+
+      // Add a response to the cache and try fetching it with a deep copy of
+      //  the original request
+      await store.put(request, requestValue)
+      deepStrictEqual(store.get(structuredClone(request)), requestValue)
+
+      const anotherRequest = {
+        origin: 'localhost',
+        path: '/asd',
+        method: 'GET',
+        headers: {}
+      }
+      const anotherValue = {
+        complete: true,
+        statusCode: 200,
+        statusMessage: '',
+        rawHeaders: [1, 2, 3],
+        rawTrailers: [4, 5, 6],
+        body: ['part1', 'part2'],
+        size: 16,
+        cachedAt: Date.now(),
+        staleAt: Date.now() + 10000,
+        deleteAt: Date.now() + 20000
+      }
+
+      // We haven't cached this one yet, make sure it doesn't confuse it with
+      //  another request
+      strictEqual(await store.get(anotherRequest), undefined)
+
+      // Add a response to the cache and try fetching it with a deep copy of
+      //  the original request
+      await store.put(anotherRequest, anotherValue)
+      deepStrictEqual(store.get(structuredClone(anotherRequest)), anotherValue)
+    })
+
+    test('returns stale response if possible', async () => {
+      const request = {
+        origin: 'localhost',
+        path: '/',
+        method: 'GET',
+        headers: {}
+      }
+      const requestValue = {
+        complete: true,
+        statusCode: 200,
+        statusMessage: '',
+        rawHeaders: [1, 2, 3],
+        rawTrailers: [4, 5, 6],
+        body: ['part1', 'part2'],
+        size: 16,
+        cachedAt: Date.now() - 10000,
+        staleAt: Date.now() - 1,
+        deleteAt: Date.now() + 20000
+      }
+
+      const store = new CacheStore()
+      await store.put(request, requestValue)
+      deepStrictEqual(await store.get(request), requestValue)
+    })
+
+    test('doesn\'t return response past deletedAt', async () => {
+      const request = {
+        origin: 'localhost',
+        path: '/',
+        method: 'GET',
+        headers: {}
+      }
+      const requestValue = {
+        complete: true,
+        statusCode: 200,
+        statusMessage: '',
+        rawHeaders: [1, 2, 3],
+        rawTrailers: [4, 5, 6],
+        body: ['part1', 'part2'],
+        size: 16,
+        cachedAt: Date.now() - 20000,
+        staleAt: Date.now() - 10000,
+        deleteAt: Date.now() - 5
+      }
+
+      const store = new CacheStore()
+      await store.put(request, requestValue)
+      strictEqual(await store.get(request), undefined)
+    })
+
+    test('respects vary directives', async () => {
+      const store = new CacheStore()
+
+      const request = {
+        origin: 'localhost',
+        path: '/',
+        method: 'GET',
+        headers: {
+          'some-header': 'hello world'
+        }
+      }
+      const requestValue = {
+        complete: true,
+        statusCode: 200,
+        statusMessage: '',
+        rawHeaders: [1, 2, 3],
+        rawTrailers: [4, 5, 6],
+        body: ['part1', 'part2'],
+        vary: {
+          'some-header': 'hello world'
+        },
+        size: 16,
+        cachedAt: Date.now(),
+        staleAt: Date.now() + 10000,
+        deleteAt: Date.now() + 20000
+      }
+
+      // Sanity check
+      strictEqual(await store.get(request), undefined)
+
+      await store.put(request, requestValue)
+      deepStrictEqual(store.get(request), requestValue)
+
+      const nonMatchingRequest = {
+        origin: 'localhost',
+        path: '/',
+        method: 'GET',
+        headers: {
+          'some-header': 'another-value'
+        }
+      }
+      deepStrictEqual(store.get(nonMatchingRequest), undefined)
+
+      deepStrictEqual(store.get(structuredClone(request)), requestValue)
+    })
+  })
+}
diff --git a/test/cache-interceptor/interceptor.js b/test/cache-interceptor/interceptor.js
new file mode 100644
index 00000000000..2303649ebe5
--- /dev/null
+++ b/test/cache-interceptor/interceptor.js
@@ -0,0 +1,134 @@
+'use strict'
+
+const { describe, test, after } = require('node:test')
+const { strictEqual } = require('node:assert')
+const { createServer } = require('node:http')
+const { Client, interceptors } = require('../../index')
+const { once } = require('node:events')
+
+// e2e tests, checks just the public api stuff basically
+describe('Cache Interceptor', () => {
+  test('doesn\'t cache request w/ no cache-control header', async () => {
+    let requestsToOrigin = 0
+
+    const server = createServer((_, res) => {
+      requestsToOrigin++
+      res.end('asd')
+    }).listen(0)
+
+    after(() => server.close())
+    await once(server, 'listening')
+
+    const client = new Client(`http://localhost:${server.address().port}`)
+      .compose(interceptors.cache())
+
+    strictEqual(requestsToOrigin, 0)
+
+    // Send initial request. This should reach the origin
+    let response = await client.request({ method: 'GET', path: '/' })
+    strictEqual(requestsToOrigin, 1)
+    strictEqual(await getResponse(response.body), 'asd')
+
+    // Send second request that should be handled by cache
+    response = await client.request({ method: 'GET', path: '/' })
+    strictEqual(requestsToOrigin, 2)
+    strictEqual(await getResponse(response.body), 'asd')
+  })
+
+  test('caches request successfully', async () => {
+    let requestsToOrigin = 0
+
+    const server = createServer((_, res) => {
+      requestsToOrigin++
+      res.setHeader('cache-control', 'public, s-maxage=10')
+      res.end('asd')
+    }).listen(0)
+
+    after(() => server.close())
+    await once(server, 'listening')
+
+    const client = new Client(`http://localhost:${server.address().port}`)
+      .compose(interceptors.cache())
+
+    strictEqual(requestsToOrigin, 0)
+
+    // Send initial request. This should reach the origin
+    let response = await client.request({ method: 'GET', path: '/' })
+    strictEqual(requestsToOrigin, 1)
+    strictEqual(await getResponse(response.body), 'asd')
+
+    // Send second request that should be handled by cache
+    response = await client.request({ method: 'GET', path: '/' })
+    strictEqual(requestsToOrigin, 1)
+    strictEqual(await getResponse(response.body), 'asd')
+    strictEqual(response.headers.age, '0')
+  })
+
+  test('respects vary header', async () => {
+    let requestsToOrigin = 0
+
+    const server = createServer((req, res) => {
+      requestsToOrigin++
+      res.setHeader('cache-control', 'public, s-maxage=10')
+      res.setHeader('vary', 'some-header, another-header')
+
+      if (req.headers['some-header'] === 'abc123') {
+        res.end('asd')
+      } else {
+        res.end('dsa')
+      }
+    }).listen(0)
+
+    after(() => server.close())
+    await once(server, 'listening')
+
+    const client = new Client(`http://localhost:${server.address().port}`)
+      .compose(interceptors.cache())
+
+    strictEqual(requestsToOrigin, 0)
+
+    // Send initial request. This should reach the origin
+    let response = await client.request({
+      method: 'GET',
+      path: '/',
+      headers: {
+        'some-header': 'abc123',
+        'another-header': '123abc'
+      }
+    })
+    strictEqual(requestsToOrigin, 1)
+    strictEqual(await getResponse(response.body), 'asd')
+
+    // Make another request with changed headers, this should miss
+    const secondResponse = await client.request({
+      method: 'GET',
+      path: '/',
+      headers: {
+        'some-header': 'qwerty',
+        'another-header': 'asdfg'
+      }
+    })
+    strictEqual(requestsToOrigin, 2)
+    strictEqual(await getResponse(secondResponse.body), 'dsa')
+
+    // Resend the first request again which should still be cahced
+    response = await client.request({
+      method: 'GET',
+      path: '/',
+      headers: {
+        'some-header': 'abc123',
+        'another-header': '123abc'
+      }
+    })
+    strictEqual(requestsToOrigin, 2)
+    strictEqual(await getResponse(response.body), 'asd')
+  })
+})
+
+async function getResponse (body) {
+  const buffers = []
+  for await (const data of body) {
+    buffers.push(data)
+  }
+  return Buffer.concat(buffers).toString('utf8')
+}
diff --git a/test/cache-interceptor/utils.js b/test/cache-interceptor/utils.js
new file mode 100644
index 00000000000..9b4e247ddca
--- /dev/null
+++ b/test/cache-interceptor/utils.js
@@ -0,0 +1,164 @@
+'use strict'
+
+const { describe, test } = require('node:test')
+const { deepStrictEqual } = require('node:assert')
+const { parseCacheControlHeader, parseVaryHeader } = require('../../lib/util/cache')
+
+describe('parseCacheControlHeader', () => {
+  test('all directives are parsed properly when in their correct format', () => {
+    const directives = parseCacheControlHeader(
+      'max-stale=1, min-fresh=1, max-age=1, s-maxage=1, stale-while-revalidate=1, stale-if-error=1, public, private, no-store, no-cache, must-revalidate, proxy-revalidate, immutable, no-transform, must-understand, only-if-cached'
+    )
+    deepStrictEqual(directives, {
+      'max-stale': 1,
+      'min-fresh': 1,
+      'max-age': 1,
+      's-maxage': 1,
+      'stale-while-revalidate': 1,
+      'stale-if-error': 1,
+      public: true,
+      private: true,
+      'no-store': true,
+      'no-cache': true,
+      'must-revalidate': true,
+      'proxy-revalidate': true,
+      immutable: true,
+      'no-transform': true,
+      'must-understand': true,
+      'only-if-cached': true
+    })
+  })
+
+  test('handles weird spacings', () => {
+    const directives = parseCacheControlHeader(
+      'max-stale=1, min-fresh=1,     max-age=1,s-maxage=1,  stale-while-revalidate=1,stale-if-error=1,public,private'
+    )
+    deepStrictEqual(directives, {
+      'max-stale': 1,
+      'min-fresh': 1,
+      'max-age': 1,
+      's-maxage': 1,
+      'stale-while-revalidate': 1,
+      'stale-if-error': 1,
+      public: true,
+      private: true
+    })
+  })
+
+  test('unknown directives are ignored', () => {
+    const directives = parseCacheControlHeader('max-age=123, something-else=456')
+    deepStrictEqual(directives, { 'max-age': 123 })
+  })
+
+  test('directives with incorrect types are ignored', () => {
+    const directives = parseCacheControlHeader('max-age=true, only-if-cached=123')
+    deepStrictEqual(directives, {})
+  })
+
+  test('the last instance of a directive takes precedence', () => {
+    const directives = parseCacheControlHeader('max-age=1, max-age=2')
+    deepStrictEqual(directives, { 'max-age': 2 })
+  })
+
+  test('case insensitive', () => {
+    const directives = parseCacheControlHeader('Max-Age=123')
+    deepStrictEqual(directives, { 'max-age': 123 })
+  })
+
+  test('no-cache with headers', () => {
+    let directives = parseCacheControlHeader('max-age=10, no-cache=some-header, only-if-cached')
+    deepStrictEqual(directives, {
+      'max-age': 10,
+      'no-cache': [
+        'some-header'
+      ],
+      'only-if-cached': true
+    })
+
+    directives = parseCacheControlHeader('max-age=10, no-cache="some-header", only-if-cached')
+    deepStrictEqual(directives, {
+      'max-age': 10,
+      'no-cache': [
+        'some-header'
+      ],
+      'only-if-cached': true
+    })
+
+    directives = parseCacheControlHeader('max-age=10, no-cache="some-header, another-one", only-if-cached')
+    deepStrictEqual(directives, {
+      'max-age': 10,
+      'no-cache': [
+        'some-header',
+        'another-one'
+      ],
+      'only-if-cached': true
+    })
+  })
+
+  test('private with headers', () => {
+    let directives = parseCacheControlHeader('max-age=10, private=some-header, only-if-cached')
+    deepStrictEqual(directives, {
+      'max-age': 10,
+      private: [
+        'some-header'
+      ],
+      'only-if-cached': true
+    })
+
+    directives = parseCacheControlHeader('max-age=10, private="some-header", only-if-cached')
+    deepStrictEqual(directives, {
+      'max-age': 10,
+      private: [
+        'some-header'
+      ],
+      'only-if-cached': true
+    })
+
+    directives = parseCacheControlHeader('max-age=10, private="some-header, another-one", only-if-cached')
+    deepStrictEqual(directives, {
+      'max-age': 10,
+      private: [
+        'some-header',
+        'another-one'
+      ],
+      'only-if-cached': true
+    })
+
+    // Missing ending quote, invalid & should be skipped
+    directives = parseCacheControlHeader('max-age=10, private="some-header, another-one, only-if-cached')
+    deepStrictEqual(directives, {
+      'max-age': 10,
+      'only-if-cached': true
+    })
+  })
+})
+
+describe('parseVaryHeader', () => {
+  test('basic usage', () => {
+    const output = parseVaryHeader({
+      vary: 'some-header, another-one',
+      'some-header': 'asd',
+      'another-one': '123',
+      'third-header': 'cool'
+    })
+    deepStrictEqual(output, new Map([
+      ['some-header', 'asd'],
+      ['another-one', '123']
+    ]))
+  })
+
+  test('handles weird spacings', () => {
+    const output = parseVaryHeader({
+      vary: 'some-header,    another-one,something-else',
+      'some-header': 'asd',
+      'another-one': '123',
+      'something-else': 'asd123',
+      'third-header': 'cool'
+    })
+    deepStrictEqual(output, new Map([
+      ['some-header', 'asd'],
+      ['another-one', '123'],
+      ['something-else', 'asd123']
+    ]))
+  })
+})
diff --git a/types/cache-interceptor.d.ts b/types/cache-interceptor.d.ts
new file mode 100644
index 00000000000..a7275871960
--- /dev/null
+++ b/types/cache-interceptor.d.ts
@@ -0,0 +1,79 @@
+import Dispatcher from './dispatcher'
+
+export default CacheHandler
+
+declare namespace CacheHandler {
+  export interface CacheOptions {
+    store?: CacheStore
+
+    /**
+     * The methods to cache, defaults to just GET
+     */
+    methods?: ('GET' | 'HEAD' | 'POST' | 'PATCH')[]
+  }
+
+  /**
+   * Underlying storage provider for cached responses
+   */
+  export interface CacheStore {
+    /**
+     * The max size of each value. If the content-length header is greater than
+     *  this or the response ends up over this, the response will not be cached
+     * @default Infinity
+     */
+    get maxEntrySize(): number
+
+    get(key: Dispatcher.RequestOptions): CacheStoreValue[] | Promise<CacheStoreValue[]>;
+
+    put(key: Dispatcher.RequestOptions, opts: CacheStoreValue): void | Promise<void>;
+  }
+
+  export interface CacheStoreValue {
+    /**
+     * True if the response is complete, otherwise the request is still in-flight
+     */
+    complete: boolean;
+    statusCode: number;
+    statusMessage: string;
+    rawHeaders: Buffer[];
+    rawTrailers?: Buffer[];
+    body: string[]
+    /**
+     * Headers defined by the Vary header and their respective values for
+     *  later comparison
+     */
+    vary?: Record<string, string>;
+    /**
+     * Actual size of the response (i.e. size of headers + body + trailers)
+     */
+    size: number;
+    /**
+     * Time in millis that this value was cached
+     */
+    cachedAt: number;
+    /**
+     * Time in millis that this value is considered stale
+     */
+    staleAt: number;
+    /**
+     * Time in millis that this value is to be deleted from the cache. This is
+     *  either the same as staleAt or the `max-stale` caching directive.
+     */
+    deleteAt: number;
+  }
+
+  export interface MemoryCacheStoreOpts {
+    /**
+     * @default Infinity
+     */
+    maxEntrySize?: number
+  }
+
+  export class MemoryCacheStore implements CacheStore {
+    constructor (opts?: MemoryCacheStoreOpts)
+
+    get maxEntrySize (): number
+    get (key: Dispatcher.RequestOptions): CacheStoreValue[] | Promise<CacheStoreValue[]>
+    put (key: Dispatcher.RequestOptions, opts: CacheStoreValue): Promise<void>
+  }
+}
diff --git a/types/index.d.ts b/types/index.d.ts
index 45276234925..fed36ab8643 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -64,4 +64,7 @@ declare namespace Undici {
   const FormData: typeof import('./formdata').FormData
   const caches: typeof import('./cache').caches
   const interceptors: typeof import('./interceptors').default
+  const cacheStores: {
+    MemoryCacheStore: typeof import('./cache-interceptor').default.MemoryCacheStore
+  }
 }
diff --git a/types/interceptors.d.ts b/types/interceptors.d.ts
index 53835e01299..ee15b1c6f0e 100644
--- a/types/interceptors.d.ts
+++ b/types/interceptors.d.ts
@@ -1,3 +1,4 @@
+import CacheHandler from './cache-interceptor'
 import Dispatcher from './dispatcher'
 import RetryHandler from './retry-handler'
 
@@ -8,10 +9,12 @@ declare namespace Interceptors {
   export type RetryInterceptorOpts = RetryHandler.RetryOptions
   export type RedirectInterceptorOpts = { maxRedirections?: number }
   export type ResponseErrorInterceptorOpts = { throwOnError: boolean }
+  export type CacheInterceptorOpts = CacheHandler.CacheOptions
 
   export function createRedirectInterceptor (opts: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
   export function dump (opts?: DumpInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
   export function retry (opts?: RetryInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
   export function redirect (opts?: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
   export function responseError (opts?: ResponseErrorInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
+  export function cache (opts?: CacheInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
 }