diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 238556f0cf03ddd972189a4dc8bf29afe77aca10..9020f90fd3c48e2b44b6e3174958b38b8cd52452 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -472,12 +472,8 @@ def multiple_issue_boards_available?
     false
   end
 
-  def all_project_ids
-    all_projects.pluck(:id)
-  end
-
   def all_project_ids_except(ids)
-    all_projects.where.not(id: ids).pluck(:id)
+    all_project_ids.where.not(id: ids)
   end
 
   # Deprecated, use #licensed_feature_available? instead. Remove once Namespace#feature_available? isn't used anymore.
diff --git a/app/models/namespaces/traversal/cached.rb b/app/models/namespaces/traversal/cached.rb
index 55eaaa4667ef17261073d45fe9f67d18b11ee460..b962038d03933d2439cff54f62fc602917d60fc1 100644
--- a/app/models/namespaces/traversal/cached.rb
+++ b/app/models/namespaces/traversal/cached.rb
@@ -10,8 +10,62 @@ module Cached
         after_destroy :invalidate_descendants_cache
       end
 
+      override :self_and_descendant_ids
+      def self_and_descendant_ids
+        return super unless attempt_to_use_cached_data?
+
+        scope_with_cached_ids(
+          super,
+          self.class,
+          Namespaces::Descendants.arel_table[:self_and_descendant_group_ids]
+        )
+      end
+
+      override :all_project_ids
+      def all_project_ids
+        return super unless attempt_to_use_cached_data?
+
+        scope_with_cached_ids(
+          all_projects.select(:id),
+          Project,
+          Namespaces::Descendants.arel_table[:all_project_ids]
+        )
+      end
+
       private
 
+      # This method implements an OR based cache lookup using COALESCE, similar what you would do in Ruby:
+      # return cheap_cached_data || expensive_uncached_data
+      def scope_with_cached_ids(consistent_ids_scope, model, cached_ids_column)
+        # Look up the cached ids and unnest them into rows if the cache is up to date.
+        cache_lookup_query = Namespaces::Descendants
+          .where(outdated_at: nil, namespace_id: id)
+          .select(cached_ids_column.as('ids'))
+
+        # Invoke the consistent lookup query and collect the ids as a single array value
+        consistent_descendant_ids_scope = model
+          .from(consistent_ids_scope.arel.as(model.table_name))
+          .reselect(Arel::Nodes::NamedFunction.new('ARRAY_AGG', [model.arel_table[:id]]).as('ids'))
+          .unscope(where: :type)
+
+        from = <<~SQL
+        UNNEST(
+          COALESCE(
+            (SELECT ids FROM (#{cache_lookup_query.to_sql}) cached_query),
+            (SELECT ids FROM (#{consistent_descendant_ids_scope.to_sql}) consistent_query))
+        ) AS #{model.table_name}(id)
+        SQL
+
+        model
+          .from(from)
+          .unscope(where: :type)
+          .select(:id)
+      end
+
+      def attempt_to_use_cached_data?
+        Feature.enabled?(:group_hierarchy_optimization, self, type: :beta)
+      end
+
       override :sync_traversal_ids
       def sync_traversal_ids
         super
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index c3348c49ea1130c3aae55b18ffae763f9a95d1dc..5779b777fd7e28e193650598bab8eacbeaa7d826 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -106,6 +106,10 @@ def root_ancestor
         end
       end
 
+      def all_project_ids
+        all_projects.select(:id)
+      end
+
       def self_and_descendants
         return super unless use_traversal_ids?
 
diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb
index 1c5d395cb3c117fcc3308477100e418126011425..3d551243cfb348e4bad05cf3149d3db51528fab8 100644
--- a/app/models/namespaces/traversal/recursive.rb
+++ b/app/models/namespaces/traversal/recursive.rb
@@ -19,6 +19,12 @@ def root_ancestor
       end
       alias_method :recursive_root_ancestor, :root_ancestor
 
+      def all_project_ids
+        namespace = user_namespace? ? self : recursive_self_and_descendant_ids
+        Project.where(namespace: namespace).select(:id)
+      end
+      alias_method :recursive_all_project_ids, :all_project_ids
+
       # Returns all ancestors, self, and descendants of the current namespace.
       def self_and_hierarchy
         object_hierarchy(self.class.where(id: id))
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index a6ef8c8743b4e5c9fdc9f11a44837ceb8dc20a3b..bdf943091e9f711e16185d5b2bc965e1f9182c89 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -61,7 +61,8 @@ def handle_hierarchy_cache_update
         params[:namespace_descendants_attributes] = {
           traversal_ids: group.traversal_ids,
           all_project_ids: [],
-          self_and_descendant_group_ids: []
+          self_and_descendant_group_ids: [],
+          outdated_at: Time.current
         }
       else
         return unless group.namespace_descendants
diff --git a/ee/app/services/security/orchestration/assign_service.rb b/ee/app/services/security/orchestration/assign_service.rb
index 083b77c88b3fc06929b09cd3f880c24b14de18f8..c5a9b1279c0059e3e8b93a9503b91592bc4a52a0 100644
--- a/ee/app/services/security/orchestration/assign_service.rb
+++ b/ee/app/services/security/orchestration/assign_service.rb
@@ -107,7 +107,7 @@ def create_security_policy_project_bot
         if container.is_a?(Project)
           Security::OrchestrationConfigurationCreateBotWorker.perform_async(container.id, current_user.id)
         else
-          container.all_project_ids.each do |project_id|
+          container.all_project_ids.pluck_primary_key.each do |project_id|
             Security::OrchestrationConfigurationCreateBotWorker.perform_async(project_id, current_user.id)
           end
         end
diff --git a/ee/app/services/security/orchestration/unassign_service.rb b/ee/app/services/security/orchestration/unassign_service.rb
index 3ea4b2d5bd69a9ee9d91c6d4b9472e346e373a37..3a2466ef2637c5a635cf4d4466c8566b461ca179 100644
--- a/ee/app/services/security/orchestration/unassign_service.rb
+++ b/ee/app/services/security/orchestration/unassign_service.rb
@@ -42,7 +42,7 @@ def remove_bot
         if container.is_a?(Project)
           Security::OrchestrationConfigurationRemoveBotWorker.perform_async(container.id, current_user.id)
         else
-          container.all_project_ids.each do |project_id|
+          container.all_project_ids.pluck_primary_key.each do |project_id|
             Security::OrchestrationConfigurationRemoveBotWorker.perform_async(project_id, current_user.id)
           end
         end
diff --git a/ee/lib/elastic/latest/application_class_proxy.rb b/ee/lib/elastic/latest/application_class_proxy.rb
index d8dd3434db6722f79f0e44aa20ffa43b1c43edb4..2c49a29c2eabcc345fb67dd8ec6a39bed5c83c4f 100644
--- a/ee/lib/elastic/latest/application_class_proxy.rb
+++ b/ee/lib/elastic/latest/application_class_proxy.rb
@@ -377,7 +377,7 @@ def rejected_project_filter(namespaces, options)
 
         project_ids = filter_ids_by_feature(scoped_project_ids, current_user, options[:features])
         rejected_ids = namespaces.flat_map do |namespace|
-          namespace.all_project_ids_except(project_ids)
+          namespace.all_project_ids_except(project_ids).pluck_primary_key
         end
 
         {
diff --git a/spec/factories/namespaces/descendants.rb b/spec/factories/namespaces/descendants.rb
index 6325481294ae9c7988dca60b0a9c34a7e369171e..8251c396168ac1fe0255f4d3a88abece4622af77 100644
--- a/spec/factories/namespaces/descendants.rb
+++ b/spec/factories/namespaces/descendants.rb
@@ -8,5 +8,11 @@
     traversal_ids { namespace.traversal_ids }
     outdated_at { nil }
     calculated_at { Time.current }
+
+    trait :up_to_date do
+      after(:create) do |record|
+        record.reload.update!(outdated_at: nil)
+      end
+    end
   end
 end
diff --git a/spec/models/namespaces/traversal/cached_spec.rb b/spec/models/namespaces/traversal/cached_spec.rb
index 8263e28bb985399e2955cc64e22a33cf493cc8a9..dd52f9c3d70542febb5bf97600b1cd918cc08e74 100644
--- a/spec/models/namespaces/traversal/cached_spec.rb
+++ b/spec/models/namespaces/traversal/cached_spec.rb
@@ -3,101 +3,175 @@
 require 'spec_helper'
 
 RSpec.describe Namespaces::Traversal::Cached, feature_category: :database do
-  let_it_be_with_refind(:old_parent) { create(:group) }
-  let_it_be_with_refind(:new_parent) { create(:group) }
-  let_it_be_with_refind(:group) { create(:group, parent: old_parent) }
-  let_it_be_with_refind(:subgroup) { create(:group, parent: group) }
+  describe 'callbacks' do
+    let_it_be_with_refind(:old_parent) { create(:group) }
+    let_it_be_with_refind(:new_parent) { create(:group) }
+    let_it_be_with_refind(:group) { create(:group, parent: old_parent) }
+    let_it_be_with_refind(:subgroup) { create(:group, parent: group) }
 
-  context 'when the namespace_descendants_cache_expiration feature flag is off' do
-    let!(:cache) { create(:namespace_descendants, namespace: group) }
+    context 'when the namespace_descendants_cache_expiration feature flag is off' do
+      let!(:cache) { create(:namespace_descendants, namespace: group) }
 
-    before do
-      stub_feature_flags(namespace_descendants_cache_expiration: false)
-    end
+      before do
+        stub_feature_flags(namespace_descendants_cache_expiration: false)
+      end
 
-    it 'does not invalidate the cache' do
-      expect { group.update!(parent: new_parent) }.not_to change { cache.reload.outdated_at }
-    end
+      it 'does not invalidate the cache' do
+        expect { group.update!(parent: new_parent) }.not_to change { cache.reload.outdated_at }
+      end
 
-    context 'when the group is deleted' do
-      it 'invalidates the cache' do
-        expect { group.destroy! }.not_to change { cache.reload.outdated_at }
+      context 'when the group is deleted' do
+        it 'invalidates the cache' do
+          expect { group.destroy! }.not_to change { cache.reload.outdated_at }
+        end
       end
     end
-  end
 
-  context 'when no cached records are present' do
-    it 'does nothing' do
-      group.parent = new_parent
+    context 'when no cached records are present' do
+      it 'does nothing' do
+        group.parent = new_parent
 
-      expect { group.save! }.not_to change { Namespaces::Descendants.all.to_a }
+        expect { group.save! }.not_to change { Namespaces::Descendants.all.to_a }
+      end
     end
-  end
 
-  context 'when the namespace record is UserNamespace' do
-    it 'does nothing' do
-      # we won't use the optimization for UserNamespace
-      namespace = create(:user_namespace)
-      cache = create(:namespace_descendants, namespace: namespace)
+    context 'when the namespace record is UserNamespace' do
+      it 'does nothing' do
+        # we won't use the optimization for UserNamespace
+        namespace = create(:user_namespace)
+        cache = create(:namespace_descendants, namespace: namespace)
 
-      expect { namespace.destroy! }.not_to change { cache.reload.outdated_at }
+        expect { namespace.destroy! }.not_to change { cache.reload.outdated_at }
+      end
     end
-  end
 
-  context 'when cached record is present' do
-    let!(:cache) { create(:namespace_descendants, namespace: group) }
+    context 'when cached record is present' do
+      let!(:cache) { create(:namespace_descendants, namespace: group) }
 
-    it 'invalidates the cache' do
-      expect { group.update!(parent: new_parent) }.to change { cache.reload.outdated_at }.from(nil)
-    end
+      it 'invalidates the cache' do
+        expect { group.update!(parent: new_parent) }.to change { cache.reload.outdated_at }.from(nil)
+      end
 
-    it 'does not invalidate the cache of subgroups' do
-      subgroup_cache = create(:namespace_descendants, namespace: subgroup)
+      it 'does not invalidate the cache of subgroups' do
+        subgroup_cache = create(:namespace_descendants, namespace: subgroup)
 
-      expect { group.update!(parent: new_parent) }.not_to change { subgroup_cache.reload.outdated_at }
+        expect { group.update!(parent: new_parent) }.not_to change { subgroup_cache.reload.outdated_at }
+      end
+
+      context 'when a new subgroup is added' do
+        it 'invalidates the cache' do
+          expect { create(:group, parent: group) }.to change { cache.reload.outdated_at }
+        end
+      end
+
+      context 'when a new project is added' do
+        it 'invalidates the cache' do
+          expect { create(:project, group: group) }.to change { cache.reload.outdated_at }
+        end
+      end
     end
 
-    context 'when a new subgroup is added' do
-      it 'invalidates the cache' do
-        expect { create(:group, parent: group) }.to change { cache.reload.outdated_at }
+    context 'when parent group has cached record' do
+      it 'invalidates the parent cache' do
+        old_parent_cache = create(:namespace_descendants, namespace: old_parent)
+        new_parent_cache = create(:namespace_descendants, namespace: new_parent)
+
+        group.update!(parent: new_parent)
+
+        expect(old_parent_cache.reload.outdated_at).not_to be_nil
+        expect(new_parent_cache.reload.outdated_at).not_to be_nil
       end
     end
 
-    context 'when a new project is added' do
+    context 'when group is destroyed' do
       it 'invalidates the cache' do
-        expect { create(:project, group: group) }.to change { cache.reload.outdated_at }
+        cache = create(:namespace_descendants, namespace: group)
+
+        expect { group.destroy! }.to change { cache.reload.outdated_at }.from(nil)
       end
-    end
-  end
 
-  context 'when parent group has cached record' do
-    it 'invalidates the parent cache' do
-      old_parent_cache = create(:namespace_descendants, namespace: old_parent)
-      new_parent_cache = create(:namespace_descendants, namespace: new_parent)
+      context 'when parent group has cached record' do
+        it 'invalidates the parent cache' do
+          old_parent_cache = create(:namespace_descendants, namespace: old_parent)
+          new_parent_cache = create(:namespace_descendants, namespace: new_parent)
 
-      group.update!(parent: new_parent)
+          group.destroy!
 
-      expect(old_parent_cache.reload.outdated_at).not_to be_nil
-      expect(new_parent_cache.reload.outdated_at).not_to be_nil
+          expect(old_parent_cache.reload.outdated_at).not_to be_nil
+          expect(new_parent_cache.reload.outdated_at).to be_nil # no change
+        end
+      end
     end
   end
 
-  context 'when group is destroyed' do
-    it 'invalidates the cache' do
-      cache = create(:namespace_descendants, namespace: group)
+  describe 'query methods' do
+    let_it_be(:group) { create(:group) }
+    let_it_be(:subgroup) { create(:group, parent: group) }
+    let_it_be(:subsubgroup) { create(:group, parent: subgroup) }
+
+    let_it_be(:project1) { create(:project, group: group) }
+    let_it_be(:project2) { create(:project, group: subsubgroup) }
+
+    # deliberately making self_and_descendant_group_ids different from  the actual
+    # self_and_descendant_ids so we can verify that the cached query is running.
+    let_it_be_with_refind(:namespace_descendants) do
+      create(:namespace_descendants,
+        :up_to_date,
+        namespace: group,
+        self_and_descendant_group_ids: [group.id, subgroup.id],
+        all_project_ids: [project1.id]
+      )
+    end
+
+    describe '#self_and_descendant_ids' do
+      subject(:ids) { group.self_and_descendant_ids.pluck(:id) }
+
+      it 'returns the cached values' do
+        expect(ids).to eq(namespace_descendants.self_and_descendant_group_ids)
+      end
+
+      context 'when the cache is outdated' do
+        it 'returns the values from the uncached self_and_descendant_ids query' do
+          namespace_descendants.update!(outdated_at: Time.current)
+
+          expect(ids.sort).to eq([group.id, subgroup.id, subsubgroup.id])
+        end
+      end
+
+      context 'when the group_hierarchy_optimization feature flag is disabled' do
+        before do
+          stub_feature_flags(group_hierarchy_optimization: false)
+        end
 
-      expect { group.destroy! }.to change { cache.reload.outdated_at }.from(nil)
+        it 'returns the values from the uncached self_and_descendant_ids query' do
+          expect(ids.sort).to eq([group.id, subgroup.id, subsubgroup.id])
+        end
+      end
     end
 
-    context 'when parent group has cached record' do
-      it 'invalidates the parent cache' do
-        old_parent_cache = create(:namespace_descendants, namespace: old_parent)
-        new_parent_cache = create(:namespace_descendants, namespace: new_parent)
+    describe '#all_project_ids' do
+      subject(:ids) { group.all_project_ids.pluck(:id) }
 
-        group.destroy!
+      it 'returns the cached values' do
+        expect(ids).to eq(namespace_descendants.all_project_ids)
+      end
 
-        expect(old_parent_cache.reload.outdated_at).not_to be_nil
-        expect(new_parent_cache.reload.outdated_at).to be_nil # no change
+      context 'when the cache is outdated' do
+        it 'returns the values from the uncached all_project_ids query' do
+          namespace_descendants.update!(outdated_at: Time.current)
+
+          expect(ids.sort).to eq([project1.id, project2.id])
+        end
+      end
+
+      context 'when the group_hierarchy_optimization feature flag is disabled' do
+        before do
+          stub_feature_flags(group_hierarchy_optimization: false)
+        end
+
+        it 'returns the values from the uncached all_project_ids query' do
+          expect(ids.sort).to eq([project1.id, project2.id])
+        end
       end
     end
   end
diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb
index f50163041f8a79b6efcc47f4552f9403dd1c24b5..ceb0f5c45b4461b9f67446024141c351fde3f4b6 100644
--- a/spec/services/groups/update_service_spec.rb
+++ b/spec/services/groups/update_service_spec.rb
@@ -457,6 +457,8 @@
       context 'when enabling the setting' do
         it 'creates the initial Namespaces::Descendants record' do
           expect { result }.to change { public_group.reload.namespace_descendants.present? }.from(false).to(true)
+
+          expect(public_group.namespace_descendants.outdated_at).to be_present
         end
       end
 
diff --git a/spec/support/shared_examples/namespaces/traversal_examples.rb b/spec/support/shared_examples/namespaces/traversal_examples.rb
index 960160395f858a64da7cb8e96283e589bac0c563..65f1abe0355e63c13f0e2c2d0512c26c179fbe3d 100644
--- a/spec/support/shared_examples/namespaces/traversal_examples.rb
+++ b/spec/support/shared_examples/namespaces/traversal_examples.rb
@@ -284,6 +284,14 @@
     end
   end
 
+  describe 'all_project_ids' do
+    it 'is a AR relation' do
+      expect(group.all_project_ids).to be_kind_of(ActiveRecord::Relation)
+    end
+
+    it_behaves_like 'recursive version', :all_project_ids
+  end
+
   describe '#self_and_descendant_ids' do
     subject { group.self_and_descendant_ids.pluck(:id) }