diff --git a/app/models/repository.rb b/app/models/repository.rb
index 9d45a12fa6e4451d7cc855fee29098e0db426491..6f63cd32da49f6e914ca96160cd98882e8a82b0b 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -389,11 +389,15 @@ def expire_content_cache
     expire_statistics_caches
   end
 
-  # Runs code after a repository has been created.
-  def after_create
+  def expire_status_cache
     expire_exists_cache
     expire_root_ref_cache
     expire_emptiness_caches
+  end
+
+  # Runs code after a repository has been created.
+  def after_create
+    expire_status_cache
 
     repository_event(:create_repository)
   end
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index 1db18fcf4015b3c09e15aaecde9602fae4b93f31..3fd3844419601e3f06e86fe11b4aef811300e809 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -8,8 +8,6 @@ class BaseHooksService < ::BaseService
     PROCESS_COMMIT_LIMIT = 100
 
     def execute
-      project.repository.after_create if project.empty_repo?
-
       create_events
       create_pipelines
       execute_project_hooks
@@ -70,11 +68,11 @@ def execute_project_hooks
     end
 
     def enqueue_invalidate_cache
-      ProjectCacheWorker.perform_async(
-        project.id,
-        invalidated_file_types,
-        [:commit_count, :repository_size]
-      )
+      file_types = invalidated_file_types
+
+      return unless file_types.present?
+
+      ProjectCacheWorker.perform_async(project.id, file_types, [], false)
     end
 
     def base_params
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 622bd6f1f48aa0dd5c4b97895fac6a850dc45879..61d34981458cf1fd00b3199b776c913771d011c7 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -42,10 +42,8 @@ def process_project_changes(post_received)
     user = identify_user(post_received)
     return false unless user
 
-    # Expire the branches cache so we have updated data for this push
-    post_received.project.repository.expire_branches_cache if post_received.includes_branches?
-    # We only need to expire tags once per push
-    post_received.project.repository.expire_caches_for_tags if post_received.includes_tags?
+    # We only need to expire certain caches once per push
+    expire_caches(post_received)
 
     post_received.enum_for(:changes_refs).with_index do |(oldrev, newrev, ref), index|
       service_klass =
@@ -74,6 +72,30 @@ def process_project_changes(post_received)
     after_project_changes_hooks(post_received, user, refs.to_a, changes)
   end
 
+  # Expire the project, branch, and tag cache once per push. Schedule an
+  # update for the repository size and commit count if necessary.
+  def expire_caches(post_received)
+    project = post_received.project
+
+    project.repository.expire_status_cache if project.empty_repo?
+    project.repository.expire_branches_cache if post_received.includes_branches?
+    project.repository.expire_caches_for_tags if post_received.includes_tags?
+
+    enqueue_repository_cache_update(post_received)
+  end
+
+  def enqueue_repository_cache_update(post_received)
+    stats_to_invalidate = [:repository_size]
+    stats_to_invalidate << :commit_count if post_received.includes_default_branch?
+
+    ProjectCacheWorker.perform_async(
+      post_received.project.id,
+      [],
+      stats_to_invalidate,
+      true
+    )
+  end
+
   def after_project_changes_hooks(post_received, user, refs, changes)
     hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, user, changes, refs)
     SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks)
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index 4e8ea90313978e041553ecfbf1c0e8afa9d191a3..5ac860c93e00b691d7592c4add615cb2814b0964 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -12,13 +12,15 @@ class ProjectCacheWorker
   #         CHANGELOG.
   # statistics - An Array containing columns from ProjectStatistics to
   #              refresh, if empty all columns will be refreshed
+  # refresh_statistics - A boolean that determines whether project statistics should
+  #                     be updated.
   # rubocop: disable CodeReuse/ActiveRecord
-  def perform(project_id, files = [], statistics = [])
+  def perform(project_id, files = [], statistics = [], refresh_statistics = true)
     project = Project.find_by(id: project_id)
 
     return unless project
 
-    update_statistics(project, statistics)
+    update_statistics(project, statistics) if refresh_statistics
 
     return unless project.repository.exists?
 
diff --git a/changelogs/unreleased/sh-post-receive-cache-clear-once.yml b/changelogs/unreleased/sh-post-receive-cache-clear-once.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b677adf78d93f63e253e248ae59aaaa442b55b3a
--- /dev/null
+++ b/changelogs/unreleased/sh-post-receive-cache-clear-once.yml
@@ -0,0 +1,5 @@
+---
+title: Expire project caches once per push instead of once per ref
+merge_request: 31876
+author:
+type: performance
diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb
index 24d752b8a4b786f191b389b5862f99c8dc44186f..2a8bcd015a8c0833521a70dda3c89a44c40f7642 100644
--- a/lib/gitlab/git_post_receive.rb
+++ b/lib/gitlab/git_post_receive.rb
@@ -39,6 +39,17 @@ def includes_tags?
       end
     end
 
+    def includes_default_branch?
+      # If the branch doesn't have a default branch yet, we presume the
+      # first branch pushed will be the default.
+      return true unless project.default_branch.present?
+
+      enum_for(:changes_refs).any? do |_oldrev, _newrev, ref|
+        Gitlab::Git.branch_ref?(ref) &&
+          Gitlab::Git.branch_name(ref) == project.default_branch
+      end
+    end
+
     private
 
     def deserialize_changes(changes)
diff --git a/spec/lib/gitlab/git_post_receive_spec.rb b/spec/lib/gitlab/git_post_receive_spec.rb
index 4c20d945585bb74c7b2f39f77a851edd7953687f..f0df3794e2903fdb354b9ba828b60e61f1aed189 100644
--- a/spec/lib/gitlab/git_post_receive_spec.rb
+++ b/spec/lib/gitlab/git_post_receive_spec.rb
@@ -3,7 +3,7 @@
 require 'spec_helper'
 
 describe ::Gitlab::GitPostReceive do
-  let(:project) { create(:project) }
+  set(:project) { create(:project, :repository) }
 
   subject { described_class.new(project, "project-#{project.id}", changes.dup, {}) }
 
@@ -92,4 +92,47 @@
       end
     end
   end
+
+  describe '#includes_default_branch?' do
+    context 'with no default branch' do
+      let(:changes) do
+        <<~EOF
+          654321 210987 refs/heads/test1
+          654322 210986 refs/tags/#{project.default_branch}
+          654323 210985 refs/heads/test3
+        EOF
+      end
+
+      it 'returns false' do
+        expect(subject.includes_default_branch?).to be_falsey
+      end
+    end
+
+    context 'with a project with no default branch' do
+      let(:changes) do
+        <<~EOF
+          654321 210987 refs/heads/test1
+        EOF
+      end
+
+      it 'returns true' do
+        expect(project).to receive(:default_branch).and_return(nil)
+        expect(subject.includes_default_branch?).to be_truthy
+      end
+    end
+
+    context 'with default branch' do
+      let(:changes) do
+        <<~EOF
+          654322 210986 refs/heads/test1
+          654321 210987 refs/tags/test2
+          654323 210985 refs/heads/#{project.default_branch}
+        EOF
+      end
+
+      it 'returns true' do
+        expect(subject.includes_default_branch?).to be_truthy
+      end
+    end
+  end
 end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index e68de2e73a8d3f635dacef2271333121734c96a3..419e1dc2459a147b092e370b8024cb7b8afd7dac 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -1815,22 +1815,36 @@ def mock_gitaly(second_response)
   end
 
   describe '#after_create' do
+    it 'calls expire_status_cache' do
+      expect(repository).to receive(:expire_status_cache)
+
+      repository.after_create
+    end
+
+    it 'logs an event' do
+      expect(repository).to receive(:repository_event).with(:create_repository)
+
+      repository.after_create
+    end
+  end
+
+  describe '#expire_status_cache' do
     it 'flushes the exists cache' do
       expect(repository).to receive(:expire_exists_cache)
 
-      repository.after_create
+      repository.expire_status_cache
     end
 
     it 'flushes the root ref cache' do
       expect(repository).to receive(:expire_root_ref_cache)
 
-      repository.after_create
+      repository.expire_status_cache
     end
 
     it 'flushes the emptiness caches' do
       expect(repository).to receive(:expire_emptiness_caches)
 
-      repository.after_create
+      repository.expire_status_cache
     end
   end
 
diff --git a/spec/services/git/branch_hooks_service_spec.rb b/spec/services/git/branch_hooks_service_spec.rb
index 8af51848b7b842d2e447043c3e548b00c2f3245c..3929f51a0e2ffa3034529e74f33c4ad77379e337 100644
--- a/spec/services/git/branch_hooks_service_spec.rb
+++ b/spec/services/git/branch_hooks_service_spec.rb
@@ -158,9 +158,13 @@
     let(:blank_sha) { Gitlab::Git::BLANK_SHA }
 
     def clears_cache(extended: [])
-      expect(ProjectCacheWorker)
-        .to receive(:perform_async)
-        .with(project.id, extended, %i[commit_count repository_size])
+      expect(service).to receive(:invalidated_file_types).and_return(extended)
+
+      if extended.present?
+        expect(ProjectCacheWorker)
+          .to receive(:perform_async)
+          .with(project.id, extended, [], false)
+      end
 
       service.execute
     end
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index 3b69b81f12e4b07078eeeb48bb39f69a70d6637c..c8a0c22b0e8c8efdd9511cef1a6e4eeee85002ba 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -37,6 +37,29 @@ def perform(changes: base64_changes)
   end
 
   describe "#process_project_changes" do
+    context 'with an empty project' do
+      let(:empty_project) { create(:project, :empty_repo) }
+      let(:changes) { "123456 789012 refs/heads/tést1\n" }
+
+      before do
+        allow_any_instance_of(Gitlab::GitPostReceive).to receive(:identify).and_return(empty_project.owner)
+        allow(Gitlab::GlRepository).to receive(:parse).and_return([empty_project, Gitlab::GlRepository::PROJECT])
+      end
+
+      it 'expire the status cache' do
+        expect(empty_project.repository).to receive(:expire_status_cache)
+
+        perform
+      end
+
+      it 'schedules a cache update for commit count and size' do
+        expect(ProjectCacheWorker).to receive(:perform_async)
+                                        .with(empty_project.id, [], [:repository_size, :commit_count], true)
+
+        perform
+      end
+    end
+
     context 'empty changes' do
       it "does not call any PushService but runs after project hooks" do
         expect(Git::BranchPushService).not_to receive(:new)
@@ -67,15 +90,22 @@ def perform(changes: base64_changes)
       context "branches" do
         let(:changes) do
           <<~EOF
-            '123456 789012 refs/heads/tést1'
-            '123456 789012 refs/heads/tést2'
+            123456 789012 refs/heads/tést1
+            123456 789012 refs/heads/tést2
           EOF
         end
 
         it 'expires the branches cache' do
           expect(project.repository).to receive(:expire_branches_cache).once
 
-          described_class.new.perform(gl_repository, key_id, base64_changes)
+          perform
+        end
+
+        it 'expires the status cache' do
+          expect(project).to receive(:empty_repo?).and_return(true)
+          expect(project.repository).to receive(:expire_status_cache)
+
+          perform
         end
 
         it 'calls Git::BranchPushService' do
@@ -87,6 +117,30 @@ def perform(changes: base64_changes)
 
           perform
         end
+
+        it 'schedules a cache update for repository size only' do
+          expect(ProjectCacheWorker).to receive(:perform_async)
+                                          .with(project.id, [], [:repository_size], true)
+
+          perform
+        end
+
+        context 'with a default branch' do
+          let(:changes) do
+            <<~EOF
+              123456 789012 refs/heads/tést1
+              123456 789012 refs/heads/tést2
+              678912 123455 refs/heads/#{project.default_branch}
+            EOF
+          end
+
+          it 'schedules a cache update for commit count and size' do
+            expect(ProjectCacheWorker).to receive(:perform_async)
+                                            .with(project.id, [], [:repository_size, :commit_count], true)
+
+            perform
+          end
+        end
       end
 
       context "tags" do
@@ -107,7 +161,7 @@ def perform(changes: base64_changes)
         it 'does not expire branches cache' do
           expect(project.repository).not_to receive(:expire_branches_cache)
 
-          described_class.new.perform(gl_repository, key_id, base64_changes)
+          perform
         end
 
         it "only invalidates tags once" do
@@ -115,7 +169,7 @@ def perform(changes: base64_changes)
           expect(project.repository).to receive(:expire_caches_for_tags).once.and_call_original
           expect(project.repository).to receive(:expire_tags_cache).once.and_call_original
 
-          described_class.new.perform(gl_repository, key_id, base64_changes)
+          perform
         end
 
         it "calls Git::TagPushService" do
@@ -129,6 +183,13 @@ def perform(changes: base64_changes)
 
           perform
         end
+
+        it 'schedules a single ProjectCacheWorker update' do
+          expect(ProjectCacheWorker).to receive(:perform_async)
+                                          .with(project.id, [], [:repository_size], true)
+
+          perform
+        end
       end
 
       context "merge-requests" do
diff --git a/spec/workers/project_cache_worker_spec.rb b/spec/workers/project_cache_worker_spec.rb
index edc55920b8eebe150c00238ad6d4296dcd409f5a..7f3c4881b89f3a2ef96386077462882156421f0d 100644
--- a/spec/workers/project_cache_worker_spec.rb
+++ b/spec/workers/project_cache_worker_spec.rb
@@ -49,6 +49,16 @@
         worker.perform(project.id, %w(readme))
       end
 
+      context 'with statistics disabled' do
+        let(:statistics) { [] }
+
+        it 'does not update the project statistics' do
+          expect(worker).not_to receive(:update_statistics)
+
+          worker.perform(project.id, [], [], false)
+        end
+      end
+
       context 'with statistics' do
         let(:statistics) { %w(repository_size) }