diff --git a/lib/platform/comment.spec.ts b/lib/platform/comment.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f516e80873d898f6e7aced3cc01f61b8567455b9
--- /dev/null
+++ b/lib/platform/comment.spec.ts
@@ -0,0 +1,178 @@
+import { mocked, platform } from '../../test/util';
+import * as _cache from '../util/cache/repository';
+import type { Cache } from '../util/cache/repository/types';
+import { ensureComment, ensureCommentRemoval } from './comment';
+
+jest.mock('.');
+jest.mock('../util/cache/repository');
+
+const cache = mocked(_cache);
+
+describe('platform/comment', () => {
+  let repoCache: Cache = {};
+  beforeEach(() => {
+    repoCache = {};
+    jest.resetAllMocks();
+    cache.getCache.mockReturnValue(repoCache);
+  });
+
+  describe('ensureComment', () => {
+    it('caches created comment', async () => {
+      platform.ensureComment.mockResolvedValueOnce(true);
+
+      const res = await ensureComment({
+        number: 1,
+        topic: 'foo',
+        content: 'bar',
+      });
+
+      expect(res).toBe(true);
+      expect(repoCache).toEqual({
+        prComments: {
+          '1': {
+            foo: '62cdb7020ff920e5aa642c3d4066950dd1f01f4d',
+          },
+        },
+      });
+    });
+
+    it('caches comment with no topic', async () => {
+      platform.ensureComment.mockResolvedValueOnce(true);
+
+      const res = await ensureComment({
+        number: 1,
+        topic: null,
+        content: 'bar',
+      });
+
+      expect(res).toBe(true);
+      expect(repoCache).toEqual({
+        prComments: {
+          '1': {
+            '': '62cdb7020ff920e5aa642c3d4066950dd1f01f4d',
+          },
+        },
+      });
+    });
+
+    it('does not cache failed comment', async () => {
+      platform.ensureComment.mockResolvedValueOnce(false);
+
+      const res = await ensureComment({
+        number: 1,
+        topic: 'foo',
+        content: 'bar',
+      });
+
+      expect(res).toBe(false);
+      expect(repoCache).toBeEmpty();
+    });
+
+    it('short-circuits if comment already exists', async () => {
+      platform.ensureComment.mockResolvedValue(true);
+
+      await ensureComment({ number: 1, topic: 'aaa', content: '111' });
+      await ensureComment({ number: 1, topic: 'aaa', content: '111' });
+
+      expect(platform.ensureComment).toHaveBeenCalledTimes(1);
+    });
+
+    it('rewrites content hash', async () => {
+      platform.ensureComment.mockResolvedValue(true);
+
+      await ensureComment({ number: 1, topic: 'aaa', content: '111' });
+      await ensureComment({ number: 1, topic: 'aaa', content: '222' });
+
+      expect(platform.ensureComment).toHaveBeenCalledTimes(2);
+      expect(repoCache).toEqual({
+        prComments: {
+          '1': {
+            aaa: '1c6637a8f2e1f75e06ff9984894d6bd16a3a36a9',
+          },
+        },
+      });
+    });
+
+    it('caches comments many comments with different topics', async () => {
+      platform.ensureComment.mockResolvedValue(true);
+
+      await ensureComment({ number: 1, topic: 'aaa', content: '111' });
+      await ensureComment({ number: 1, topic: 'aaa', content: '111' });
+
+      await ensureComment({ number: 1, topic: 'bbb', content: '111' });
+      await ensureComment({ number: 1, topic: 'bbb', content: '111' });
+
+      expect(platform.ensureComment).toHaveBeenCalledTimes(2);
+      expect(repoCache).toEqual({
+        prComments: {
+          '1': {
+            aaa: '6216f8a75fd5bb3d5f22b6f9958cdede3fc086c2',
+            bbb: '6216f8a75fd5bb3d5f22b6f9958cdede3fc086c2',
+          },
+        },
+      });
+    });
+  });
+
+  describe('ensureCommentRemoval', () => {
+    beforeEach(() => {
+      platform.ensureCommentRemoval.mockResolvedValueOnce();
+      platform.ensureComment.mockResolvedValue(true);
+    });
+
+    it('deletes cached comment by topic', async () => {
+      await ensureComment({ number: 1, topic: 'aaa', content: '111' });
+      await ensureCommentRemoval({ type: 'by-topic', number: 1, topic: 'aaa' });
+      expect(repoCache).toEqual({
+        prComments: { '1': {} },
+      });
+    });
+
+    it('deletes cached comment by content', async () => {
+      await ensureComment({ number: 1, topic: 'aaa', content: '111' });
+      await ensureCommentRemoval({
+        type: 'by-content',
+        number: 1,
+        content: '111',
+      });
+      expect(repoCache).toEqual({
+        prComments: { '1': {} },
+      });
+    });
+
+    it('deletes by content only one comment', async () => {
+      await ensureComment({ number: 1, topic: 'aaa', content: '111' });
+      await ensureComment({ number: 1, topic: 'bbb', content: '111' });
+      await ensureCommentRemoval({
+        type: 'by-content',
+        number: 1,
+        content: '111',
+      });
+      expect(repoCache).toEqual({
+        prComments: {
+          '1': {
+            bbb: '6216f8a75fd5bb3d5f22b6f9958cdede3fc086c2',
+          },
+        },
+      });
+    });
+
+    it('deletes only for selected PR', async () => {
+      await ensureComment({ number: 1, topic: 'aaa', content: '111' });
+      await ensureComment({ number: 2, topic: 'aaa', content: '111' });
+      await ensureCommentRemoval({
+        type: 'by-content',
+        number: 1,
+        content: '111',
+      });
+      expect(repoCache).toEqual({
+        prComments: {
+          '1': {},
+          '2': {
+            aaa: '6216f8a75fd5bb3d5f22b6f9958cdede3fc086c2',
+          },
+        },
+      });
+    });
+  });
+});
diff --git a/lib/platform/comment.ts b/lib/platform/comment.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c361b6d69ee67408bd3036e1188ceaef5e055649
--- /dev/null
+++ b/lib/platform/comment.ts
@@ -0,0 +1,53 @@
+import hasha from 'hasha';
+import { getCache } from '../util/cache/repository';
+import type { EnsureCommentConfig, EnsureCommentRemovalConfig } from './types';
+import { platform } from '.';
+
+const hash = (content: string): string => hasha(content, { algorithm: 'sha1' });
+
+export async function ensureComment(
+  commentConfig: EnsureCommentConfig
+): Promise<boolean> {
+  const { number, content } = commentConfig;
+  const topic = commentConfig.topic ?? '';
+
+  const contentHash = hash(content);
+  const repoCache = getCache();
+
+  if (contentHash !== repoCache.prComments?.[number]?.[topic]) {
+    const res = await platform.ensureComment(commentConfig);
+    if (res) {
+      repoCache.prComments ??= {};
+      repoCache.prComments[number] ??= {};
+      repoCache.prComments[number][topic] = contentHash;
+    }
+    return res;
+  }
+
+  return true;
+}
+
+export async function ensureCommentRemoval(
+  config: EnsureCommentRemovalConfig
+): Promise<void> {
+  await platform.ensureCommentRemoval(config);
+
+  const repoCache = getCache();
+
+  const { type, number } = config;
+  if (repoCache.prComments?.[number]) {
+    if (type === 'by-topic') {
+      delete repoCache.prComments?.[number]?.[config.topic];
+    } else if (type === 'by-content') {
+      const contentHash = hash(config.content);
+      for (const [cachedTopic, cachedContentHash] of Object.entries(
+        repoCache.prComments?.[number]
+      )) {
+        if (cachedContentHash === contentHash) {
+          delete repoCache.prComments?.[number]?.[cachedTopic];
+          return;
+        }
+      }
+    }
+  }
+}
diff --git a/lib/util/cache/repository/types.ts b/lib/util/cache/repository/types.ts
index 7ba2a0e3252ad16d5045ea6ec3ec21187cd50b9d..55ae60d4d1a1f45a2484cada700dce4a8186cf21 100644
--- a/lib/util/cache/repository/types.ts
+++ b/lib/util/cache/repository/types.ts
@@ -52,4 +52,5 @@ export interface Cache {
     };
   };
   gitConflicts?: GitConflictsCache;
+  prComments?: Record<number, Record<string, string>>;
 }
diff --git a/lib/workers/branch/handle-existing.ts b/lib/workers/branch/handle-existing.ts
index 89026ccc481750a4ad1dd431238495d2283422d3..ac1c249cc2a301f86880b5aaa15444d4a7c87cb7 100644
--- a/lib/workers/branch/handle-existing.ts
+++ b/lib/workers/branch/handle-existing.ts
@@ -1,6 +1,7 @@
 import { GlobalConfig } from '../../config/global';
 import { logger } from '../../logger';
-import { Pr, platform } from '../../platform';
+import type { Pr } from '../../platform';
+import { ensureComment } from '../../platform/comment';
 import { PrState } from '../../types';
 import { branchExists, deleteBranch } from '../../util/git';
 import * as template from '../../util/template';
@@ -24,7 +25,7 @@ export async function handlepr(config: BranchConfig, pr: Pr): Promise<void> {
           `DRY-RUN: Would ensure closed PR comment in PR #${pr.number}`
         );
       } else {
-        await platform.ensureComment({
+        await ensureComment({
           number: pr.number,
           topic: config.userStrings.ignoreTopic,
           content,
diff --git a/lib/workers/branch/index.ts b/lib/workers/branch/index.ts
index a905611400e0ff40c2cf8f2e1816b374b3b5b0de..5c3377dff20cb78d64688dd8c46c0f75aed500d9 100644
--- a/lib/workers/branch/index.ts
+++ b/lib/workers/branch/index.ts
@@ -16,6 +16,7 @@ import {
 import { logger, removeMeta } from '../../logger';
 import { getAdditionalFiles } from '../../manager/npm/post-update';
 import { Pr, platform } from '../../platform';
+import { ensureComment, ensureCommentRemoval } from '../../platform/comment';
 import { BranchStatus, PrState } from '../../types';
 import { ExternalHostError } from '../../types/errors/external-host-error';
 import { getElapsedDays } from '../../util/date';
@@ -433,7 +434,7 @@ export async function processBranch(
         );
       } else {
         // Remove artifacts error comment only if this run has successfully updated artifacts
-        await platform.ensureCommentRemoval({
+        await ensureCommentRemoval({
           type: 'by-topic',
           number: branchPr.number,
           topic: artifactErrorTopic,
@@ -691,7 +692,7 @@ export async function processBranch(
               `DRY-RUN: Would ensure lock file error comment in PR #${pr.number}`
             );
           } else {
-            await platform.ensureComment({
+            await ensureComment({
               number: pr.number,
               topic: artifactErrorTopic,
               content,
diff --git a/lib/workers/pr/automerge.ts b/lib/workers/pr/automerge.ts
index 62838e0a7ea8c500c4819285dcbce07ee79e4ba1..b99a31ca723e3f5f8661119517eb7fe778b80a2c 100644
--- a/lib/workers/pr/automerge.ts
+++ b/lib/workers/pr/automerge.ts
@@ -1,6 +1,7 @@
 import { GlobalConfig } from '../../config/global';
 import { logger } from '../../logger';
 import { Pr, platform } from '../../platform';
+import { ensureComment, ensureCommentRemoval } from '../../platform/comment';
 import { BranchStatus } from '../../types';
 import {
   deleteBranch,
@@ -93,13 +94,13 @@ export async function checkAutoMerge(
       };
     }
     if (rebaseRequested) {
-      await platform.ensureCommentRemoval({
+      await ensureCommentRemoval({
         type: 'by-content',
         number: pr.number,
         content: automergeComment,
       });
     }
-    await platform.ensureComment({
+    await ensureComment({
       number: pr.number,
       topic: null,
       content: automergeComment,
diff --git a/lib/workers/pr/index.ts b/lib/workers/pr/index.ts
index 42c1d3dbd408baf8c6f688ec12ff02c0dffec7e7..383aa0ead3ecdfb316376da4658a2da0f96ccea7 100644
--- a/lib/workers/pr/index.ts
+++ b/lib/workers/pr/index.ts
@@ -7,6 +7,7 @@ import {
 } from '../../constants/error-messages';
 import { logger } from '../../logger';
 import { PlatformPrOptions, Pr, platform } from '../../platform';
+import { ensureComment } from '../../platform/comment';
 import { BranchStatus } from '../../types';
 import { ExternalHostError } from '../../types/errors/external-host-error';
 import { sampleSize } from '../../util';
@@ -493,7 +494,7 @@ export async function ensurePr(
       if (GlobalConfig.get('dryRun')) {
         logger.info(`DRY-RUN: Would add comment to PR #${pr.number}`);
       } else {
-        await platform.ensureComment({
+        await ensureComment({
           number: pr.number,
           topic,
           content,
diff --git a/lib/workers/repository/finalise/prune.ts b/lib/workers/repository/finalise/prune.ts
index 17aaf3bd05d235278a83e8bacf8c29ae76e68231..f469040ed916bcf74de7f7def7e1d80b133103f3 100644
--- a/lib/workers/repository/finalise/prune.ts
+++ b/lib/workers/repository/finalise/prune.ts
@@ -3,6 +3,7 @@ import type { RenovateConfig } from '../../../config/types';
 import { REPOSITORY_CHANGED } from '../../../constants/error-messages';
 import { logger } from '../../../logger';
 import { platform } from '../../../platform';
+import { ensureComment } from '../../../platform/comment';
 import { PrState } from '../../../types';
 import {
   deleteBranch,
@@ -34,7 +35,7 @@ async function cleanUpBranches(
           if (GlobalConfig.get('dryRun')) {
             logger.info(`DRY-RUN: Would add Autoclosing Skipped comment to PR`);
           } else {
-            await platform.ensureComment({
+            await ensureComment({
               number: pr.number,
               topic: 'Autoclosing Skipped',
               content:
diff --git a/lib/workers/repository/onboarding/branch/check.ts b/lib/workers/repository/onboarding/branch/check.ts
index c609e41b4d7c3ab717bfeee37742d9355df11ee2..9b20c29a890174135039c9d902ed4a46174f1aa3 100644
--- a/lib/workers/repository/onboarding/branch/check.ts
+++ b/lib/workers/repository/onboarding/branch/check.ts
@@ -6,6 +6,7 @@ import {
 } from '../../../../constants/error-messages';
 import { logger } from '../../../../logger';
 import { platform } from '../../../../platform';
+import { ensureComment } from '../../../../platform/comment';
 import { PrState } from '../../../../types';
 import { getCache } from '../../../../util/cache/repository';
 import { readLocalFile } from '../../../../util/fs';
@@ -110,7 +111,7 @@ export const isOnboarded = async (config: RenovateConfig): Promise<boolean> => {
   logger.debug('Repo is not onboarded and no merged PRs exist');
   if (!config.suppressNotifications.includes('onboardingClose')) {
     // ensure PR comment
-    await platform.ensureComment({
+    await ensureComment({
       number: pr.number,
       topic: `Renovate is disabled`,
       content: `Renovate is disabled due to lack of config. If you wish to reenable it, you can either (a) commit a config file to your base branch, or (b) rename this closed PR to trigger a replacement onboarding PR.`,
diff --git a/tsconfig.strict.json b/tsconfig.strict.json
index 25201cde3a32bd02cb07d92e7212b551cf47233d..b07c7d7d963b387ea9cdfa39fb4298d9066feca1 100644
--- a/tsconfig.strict.json
+++ b/tsconfig.strict.json
@@ -309,6 +309,7 @@
     "lib/platform/azure/util.ts",
     "lib/platform/bitbucket/index.ts",
     "lib/platform/bitbucket-server/index.ts",
+    "lib/platform/comment.ts",
     "lib/platform/commit.ts",
     "lib/platform/gitea/index.ts",
     "lib/platform/github/index.ts",