diff --git a/lib/util/http/bitbucket-server.ts b/lib/util/http/bitbucket-server.ts
index 449754f4cecea7ec803b125e74a8dca0a342ecde..f9a7fa3ac74d0da6a4306722ed5f7d00ce36d905 100644
--- a/lib/util/http/bitbucket-server.ts
+++ b/lib/util/http/bitbucket-server.ts
@@ -1,5 +1,10 @@
 import { resolveBaseUrl } from '../url';
-import type { HttpOptions, HttpResponse, InternalHttpOptions } from './types';
+import type {
+  HttpOptions,
+  HttpRequestOptions,
+  HttpResponse,
+  InternalHttpOptions,
+} from './types';
 import { Http } from '.';
 
 let baseUrl: string;
@@ -14,7 +19,7 @@ export class BitbucketServerHttp extends Http {
 
   protected override request<T>(
     path: string,
-    options?: InternalHttpOptions
+    options?: InternalHttpOptions & HttpRequestOptions<T>
   ): Promise<HttpResponse<T>> {
     const url = resolveBaseUrl(baseUrl, path);
     const opts = {
diff --git a/lib/util/http/bitbucket.ts b/lib/util/http/bitbucket.ts
index 0d4a51c28a9b503508247c0d951637f7857a1e4b..34c5166ca8cb7a4bb5d5b8c064417edd1c1f783b 100644
--- a/lib/util/http/bitbucket.ts
+++ b/lib/util/http/bitbucket.ts
@@ -2,7 +2,7 @@ import is from '@sindresorhus/is';
 import { logger } from '../../logger';
 import type { PagedResult } from '../../modules/platform/bitbucket/types';
 import { parseUrl, resolveBaseUrl } from '../url';
-import type { HttpOptions, HttpResponse } from './types';
+import type { HttpOptions, HttpRequestOptions, HttpResponse } from './types';
 import { Http } from '.';
 
 const MAX_PAGES = 100;
@@ -26,7 +26,7 @@ export class BitbucketHttp extends Http<BitbucketHttpOptions> {
 
   protected override async request<T>(
     path: string,
-    options?: BitbucketHttpOptions
+    options?: BitbucketHttpOptions & HttpRequestOptions<T>
   ): Promise<HttpResponse<T>> {
     const opts = { baseUrl, ...options };
 
@@ -53,7 +53,7 @@ export class BitbucketHttp extends Http<BitbucketHttpOptions> {
       while (is.nonEmptyString(nextURL) && page <= MAX_PAGES) {
         const nextResult = await super.request<PagedResult<T>>(
           nextURL,
-          options
+          options as BitbucketHttpOptions
         );
 
         resultBody.values.push(...nextResult.body.values);
diff --git a/lib/util/http/gitea.ts b/lib/util/http/gitea.ts
index 394f3589e4b0c4962d4227f393b3362b547bf9f9..32e2ea27d7aed12c7ed2dab2478336c6bd9047d1 100644
--- a/lib/util/http/gitea.ts
+++ b/lib/util/http/gitea.ts
@@ -1,6 +1,11 @@
 import is from '@sindresorhus/is';
 import { resolveBaseUrl } from '../url';
-import type { HttpOptions, HttpResponse, InternalHttpOptions } from './types';
+import type {
+  HttpOptions,
+  HttpRequestOptions,
+  HttpResponse,
+  InternalHttpOptions,
+} from './types';
 import { Http } from '.';
 
 let baseUrl: string;
@@ -36,7 +41,7 @@ export class GiteaHttp extends Http<GiteaHttpOptions> {
 
   protected override async request<T>(
     path: string,
-    options?: InternalHttpOptions & GiteaHttpOptions
+    options?: InternalHttpOptions & GiteaHttpOptions & HttpRequestOptions<T>
   ): Promise<HttpResponse<T>> {
     const resolvedUrl = resolveUrl(path, options?.baseUrl ?? baseUrl);
     const opts = {
diff --git a/lib/util/http/github.ts b/lib/util/http/github.ts
index 9dfb182d6736736102045b4c0e978f551de6e33a..440a042d3cc122594c620a5774e05a058b534dc2 100644
--- a/lib/util/http/github.ts
+++ b/lib/util/http/github.ts
@@ -19,6 +19,7 @@ import type { GotLegacyError } from './legacy';
 import type {
   GraphqlOptions,
   HttpOptions,
+  HttpRequestOptions,
   HttpResponse,
   InternalHttpOptions,
 } from './types';
@@ -271,7 +272,7 @@ export class GithubHttp extends Http<GithubHttpOptions> {
 
   protected override async request<T>(
     url: string | URL,
-    options?: InternalHttpOptions & GithubHttpOptions,
+    options?: InternalHttpOptions & GithubHttpOptions & HttpRequestOptions<T>,
     okToRetry = true
   ): Promise<HttpResponse<T>> {
     const opts: GithubHttpOptions = {
diff --git a/lib/util/http/gitlab.ts b/lib/util/http/gitlab.ts
index 6a72c31cd8f7b623c7ec806123bbc65d9e1b8268..9078bbeee0c08e2a38b9c2642010b77d41001934 100644
--- a/lib/util/http/gitlab.ts
+++ b/lib/util/http/gitlab.ts
@@ -2,7 +2,12 @@ import is from '@sindresorhus/is';
 import { logger } from '../../logger';
 import { ExternalHostError } from '../../types/errors/external-host-error';
 import { parseLinkHeader, parseUrl } from '../url';
-import type { HttpOptions, HttpResponse, InternalHttpOptions } from './types';
+import type {
+  HttpOptions,
+  HttpRequestOptions,
+  HttpResponse,
+  InternalHttpOptions,
+} from './types';
 import { Http } from '.';
 
 let baseUrl = 'https://gitlab.com/api/v4/';
@@ -21,7 +26,7 @@ export class GitlabHttp extends Http<GitlabHttpOptions> {
 
   protected override async request<T>(
     url: string | URL,
-    options?: InternalHttpOptions & GitlabHttpOptions
+    options?: InternalHttpOptions & GitlabHttpOptions & HttpRequestOptions<T>
   ): Promise<HttpResponse<T>> {
     const opts = {
       baseUrl,
diff --git a/lib/util/http/index.spec.ts b/lib/util/http/index.spec.ts
index 9563f2995654d0389bcaa5d22016b2c952d965b4..7e8668154cf204f22f394f6d85e8f0ef5239c7c5 100644
--- a/lib/util/http/index.spec.ts
+++ b/lib/util/http/index.spec.ts
@@ -425,4 +425,78 @@ describe('util/http/index', () => {
       expect(t2 - t1).toBeGreaterThanOrEqual(4000);
     });
   });
+
+  describe('Etag caching', () => {
+    it('returns cached data for status=304', async () => {
+      type FooBar = { foo: string; bar: string };
+      const data: FooBar = { foo: 'foo', bar: 'bar' };
+      httpMock
+        .scope(baseUrl, { reqheaders: { 'If-None-Match': 'foobar' } })
+        .get('/foo')
+        .reply(304);
+
+      const res = await http.getJson<FooBar>(`/foo`, {
+        baseUrl,
+        etagCache: {
+          etag: 'foobar',
+          data,
+        },
+      });
+
+      expect(res.statusCode).toBe(304);
+      expect(res.body).toEqual(data);
+      expect(res.body).not.toBe(data);
+    });
+
+    it('bypasses schema parsing', async () => {
+      const FooBar = z
+        .object({ foo: z.string(), bar: z.string() })
+        .transform(({ foo, bar }) => ({
+          foobar: `${foo}${bar}`.toUpperCase(),
+        }));
+      const data = FooBar.parse({ foo: 'foo', bar: 'bar' });
+      httpMock
+        .scope(baseUrl, { reqheaders: { 'If-None-Match': 'foobar' } })
+        .get('/foo')
+        .reply(304);
+
+      const res = await http.getJson(
+        `/foo`,
+        {
+          baseUrl,
+          etagCache: {
+            etag: 'foobar',
+            data,
+          },
+        },
+        FooBar
+      );
+
+      expect(res.statusCode).toBe(304);
+      expect(res.body).toEqual(data);
+      expect(res.body).not.toBe(data);
+    });
+
+    it('returns new data for status=200', async () => {
+      type FooBar = { foo: string; bar: string };
+      const oldData: FooBar = { foo: 'foo', bar: 'bar' };
+      const newData: FooBar = { foo: 'FOO', bar: 'BAR' };
+      httpMock
+        .scope(baseUrl, { reqheaders: { 'If-None-Match': 'foobar' } })
+        .get('/foo')
+        .reply(200, newData);
+
+      const res = await http.getJson<FooBar>(`/foo`, {
+        baseUrl,
+        etagCache: {
+          etag: 'foobar',
+          data: oldData,
+        },
+      });
+
+      expect(res.statusCode).toBe(200);
+      expect(res.body).toEqual(newData);
+      expect(res.body).not.toBe(newData);
+    });
+  });
 });
diff --git a/lib/util/http/index.ts b/lib/util/http/index.ts
index 20bca08a33bf96182e84db7f2e494a2f0190573f..f435c274609190e039632a0b171431df1527fd84 100644
--- a/lib/util/http/index.ts
+++ b/lib/util/http/index.ts
@@ -18,6 +18,7 @@ import type {
   GotJSONOptions,
   GotOptions,
   HttpOptions,
+  HttpRequestOptions,
   HttpResponse,
   InternalHttpOptions,
   RequestStats,
@@ -28,7 +29,7 @@ import './legacy';
 export { RequestError as HttpError };
 
 type JsonArgs<
-  Opts extends HttpOptions,
+  Opts extends HttpOptions & HttpRequestOptions<ResT>,
   ResT = unknown,
   Schema extends ZodType<ResT> = ZodType<ResT>
 > = {
@@ -39,18 +40,24 @@ type JsonArgs<
 
 type Task<T> = () => Promise<HttpResponse<T>>;
 
-function cloneResponse<T extends Buffer | string | any>(
-  response: HttpResponse<T>
+// Copying will help to avoid circular structure
+// and mutation of the cached response.
+function copyResponse<T extends Buffer | string | any>(
+  response: HttpResponse<T>,
+  deep: boolean
 ): HttpResponse<T> {
   const { body, statusCode, headers } = response;
-  // clone body and headers so that the cached result doesn't get accidentally mutated
-  // Don't use json clone for buffers
-  return {
-    statusCode,
-    body: body instanceof Buffer ? (body.slice() as T) : clone<T>(body),
-    headers: clone(headers),
-    authorization: !!response.authorization,
-  };
+  return deep
+    ? {
+        statusCode,
+        body: body instanceof Buffer ? (body.slice() as T) : clone<T>(body),
+        headers: clone(headers),
+      }
+    : {
+        statusCode,
+        body,
+        headers,
+      };
 }
 
 function applyDefaultHeaders(options: Options): void {
@@ -120,7 +127,7 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
 
   protected async request<T>(
     requestUrl: string | URL,
-    httpOptions: InternalHttpOptions = {}
+    httpOptions: InternalHttpOptions & HttpRequestOptions<T> = {}
   ): Promise<HttpResponse<T>> {
     let url = requestUrl.toString();
     if (httpOptions?.baseUrl) {
@@ -136,6 +143,18 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
       httpOptions
     );
 
+    const etagCache =
+      httpOptions.etagCache &&
+      (options.method === 'get' || options.method === 'head')
+        ? httpOptions.etagCache
+        : null;
+    if (etagCache) {
+      options.headers = {
+        ...options.headers,
+        'If-None-Match': etagCache.etag,
+      };
+    }
+
     if (process.env.NODE_ENV === 'test') {
       options.retry = 0;
     }
@@ -204,8 +223,10 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
 
     try {
       const res = await resPromise;
-      res.authorization = !!options?.headers?.authorization;
-      return cloneResponse(res);
+      const deepCopyNeeded = !!memCacheKey && res.statusCode !== 304;
+      const resCopy = copyResponse(res, deepCopyNeeded);
+      resCopy.authorization = !!options?.headers?.authorization;
+      return resCopy;
     } catch (err) {
       const { abortOnError, abortIgnoreStatusCodes } = options;
       if (abortOnError && !abortIgnoreStatusCodes?.includes(err.statusCode)) {
@@ -215,7 +236,10 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
     }
   }
 
-  get(url: string, options: HttpOptions = {}): Promise<HttpResponse> {
+  get(
+    url: string,
+    options: HttpOptions & HttpRequestOptions<string> = {}
+  ): Promise<HttpResponse> {
     return this.request<string>(url, options);
   }
 
@@ -235,7 +259,11 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
 
   private async requestJson<ResT = unknown>(
     method: InternalHttpOptions['method'],
-    { url, httpOptions: requestOptions, schema }: JsonArgs<Opts, ResT>
+    {
+      url,
+      httpOptions: requestOptions,
+      schema,
+    }: JsonArgs<Opts & HttpRequestOptions<ResT>, ResT>
   ): Promise<HttpResponse<ResT>> {
     const { body, ...httpOptions } = { ...requestOptions };
     const opts: InternalHttpOptions = {
@@ -253,11 +281,23 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
     }
     const res = await this.request<ResT>(url, opts);
 
+    const etagCacheHit =
+      httpOptions.etagCache && res.statusCode === 304
+        ? clone(httpOptions.etagCache.data)
+        : null;
+
     if (!schema) {
+      if (etagCacheHit) {
+        res.body = etagCacheHit;
+      }
       return res;
     }
 
-    res.body = await schema.parseAsync(res.body);
+    if (etagCacheHit) {
+      res.body = etagCacheHit;
+    } else {
+      res.body = await schema.parseAsync(res.body);
+    }
     return res;
   }
 
@@ -281,19 +321,22 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
     return res;
   }
 
-  getJson<ResT>(url: string, options?: Opts): Promise<HttpResponse<ResT>>;
+  getJson<ResT>(
+    url: string,
+    options?: Opts & HttpRequestOptions<ResT>
+  ): Promise<HttpResponse<ResT>>;
   getJson<ResT, Schema extends ZodType<ResT> = ZodType<ResT>>(
     url: string,
     schema: Schema
   ): Promise<HttpResponse<Infer<Schema>>>;
   getJson<ResT, Schema extends ZodType<ResT> = ZodType<ResT>>(
     url: string,
-    options: Opts,
+    options: Opts & HttpRequestOptions<Infer<Schema>>,
     schema: Schema
   ): Promise<HttpResponse<Infer<Schema>>>;
   getJson<ResT = unknown, Schema extends ZodType<ResT> = ZodType<ResT>>(
     arg1: string,
-    arg2?: Opts | Schema,
+    arg2?: (Opts & HttpRequestOptions<ResT>) | Schema,
     arg3?: Schema
   ): Promise<HttpResponse<ResT>> {
     const args = this.resolveArgs<ResT>(arg1, arg2, arg3);
diff --git a/lib/util/http/jira.ts b/lib/util/http/jira.ts
index df51c152a47c08d93844c32cc3ddc95ad1a9f7f0..1137cc33cce8eb6f299c48c172f587abf944d56d 100644
--- a/lib/util/http/jira.ts
+++ b/lib/util/http/jira.ts
@@ -1,4 +1,9 @@
-import type { HttpOptions, HttpResponse, InternalHttpOptions } from './types';
+import type {
+  HttpOptions,
+  HttpRequestOptions,
+  HttpResponse,
+  InternalHttpOptions,
+} from './types';
 import { Http } from '.';
 
 let baseUrl: string;
@@ -14,7 +19,7 @@ export class JiraHttp extends Http {
 
   protected override request<T>(
     url: string | URL,
-    options?: InternalHttpOptions
+    options?: InternalHttpOptions & HttpRequestOptions<T>
   ): Promise<HttpResponse<T>> {
     const opts = { baseUrl, ...options };
     return super.request<T>(url, opts);
diff --git a/lib/util/http/types.ts b/lib/util/http/types.ts
index 4b232982585ea262271f50fbf6e8d6ce32df66f5..861959a9ac122ec245d81ac347ed397f892b4722 100644
--- a/lib/util/http/types.ts
+++ b/lib/util/http/types.ts
@@ -65,6 +65,15 @@ export interface HttpOptions {
   memCache?: boolean;
 }
 
+export interface EtagCache<T = any> {
+  etag: string;
+  data: T;
+}
+
+export interface HttpRequestOptions<T = any> {
+  etagCache?: EtagCache<T>;
+}
+
 export interface InternalHttpOptions extends HttpOptions {
   json?: HttpOptions['body'];
   responseType?: 'json' | 'buffer';