diff --git a/changelogs/unreleased/sh-fix-issues-api-gitaly-nplusone.yml b/changelogs/unreleased/sh-fix-issues-api-gitaly-nplusone.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3177cb8d18cc453f35d2737b1faf396d4a96bf47
--- /dev/null
+++ b/changelogs/unreleased/sh-fix-issues-api-gitaly-nplusone.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Gitaly N+1 calls with listing issues/MRs via API
+merge_request: 31938
+author:
+type: performance
diff --git a/doc/api/issues.md b/doc/api/issues.md
index 96a547551f1bdf0495540b3910ab07b6cee1d4a9..11c18ef94bab67fdd3fb700ae5731421587688b7 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -136,7 +136,6 @@ Example response:
          "award_emoji":"http://example.com/api/v4/projects/1/issues/76/award_emoji",
          "project":"http://example.com/api/v4/projects/1"
       },
-      "subscribed": false,
       "task_completion_status":{
          "count":0,
          "completed_count":0
@@ -441,7 +440,6 @@ Example response:
          "award_emoji":"http://example.com/api/v4/projects/4/issues/41/award_emoji",
          "project":"http://example.com/api/v4/projects/4"
       },
-      "subscribed": false,
       "task_completion_status":{
          "count":0,
          "completed_count":0
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 09253ab6b0e68e42f3b6f0c26b5efc1e651ad4ca..5e66b4e76a56c834cf84de567b6c996d5c186868 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -645,7 +645,10 @@ module API
         end
       end
 
-      expose :subscribed do |issue, options|
+      # Calculating the value of subscribed field triggers Markdown
+      # processing. We can't do that for multiple issues / merge
+      # requests in a single API request.
+      expose :subscribed, if: -> (_, options) { options.fetch(:include_subscribed, true) } do |issue, options|
         issue.subscribed?(options[:current_user], options[:project] || issue.project)
       end
     end
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index d687acf3423c487b906bf68178c9c66609a64483..7819c2de515954f34bc860a032b8955354813354 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -96,7 +96,8 @@ module API
           with: Entities::Issue,
           with_labels_details: declared_params[:with_labels_details],
           current_user: current_user,
-          issuable_metadata: issuable_meta_data(issues, 'Issue', current_user)
+          issuable_metadata: issuable_meta_data(issues, 'Issue', current_user),
+          include_subscribed: false
         }
 
         present issues, options
@@ -122,7 +123,8 @@ module API
           with: Entities::Issue,
           with_labels_details: declared_params[:with_labels_details],
           current_user: current_user,
-          issuable_metadata: issuable_meta_data(issues, 'Issue', current_user)
+          issuable_metadata: issuable_meta_data(issues, 'Issue', current_user),
+          include_subscribed: false
         }
 
         present issues, options
diff --git a/spec/requests/api/issues/get_group_issues_spec.rb b/spec/requests/api/issues/get_group_issues_spec.rb
index 5916bb1151668fd474075b1d3be4e08c2ac09c16..c487471e4a1343c9adb9c955d0c56f466402b5c0 100644
--- a/spec/requests/api/issues/get_group_issues_spec.rb
+++ b/spec/requests/api/issues/get_group_issues_spec.rb
@@ -342,6 +342,14 @@ describe API::Issues do
         group_project.add_reporter(user)
       end
 
+      it 'exposes known attributes' do
+        get api(base_url, admin)
+
+        expect(response).to have_gitlab_http_status(200)
+        expect(json_response.last.keys).to include(*%w(id iid project_id title description))
+        expect(json_response.last).not_to have_key('subscribed')
+      end
+
       it 'returns all group issues (including opened and closed)' do
         get api(base_url, admin)
 
diff --git a/spec/requests/api/issues/get_project_issues_spec.rb b/spec/requests/api/issues/get_project_issues_spec.rb
index f7ca6fd1e0aa519ae6bc21656e358900445de00a..521d6b88734f1e86989d14ae59603b2daebde363 100644
--- a/spec/requests/api/issues/get_project_issues_spec.rb
+++ b/spec/requests/api/issues/get_project_issues_spec.rb
@@ -575,6 +575,7 @@ describe API::Issues do
       expect(json_response['assignee']).to be_a Hash
       expect(json_response['author']).to be_a Hash
       expect(json_response['confidential']).to be_falsy
+      expect(json_response['subscribed']).to be_truthy
     end
 
     it 'exposes the closed_at attribute' do
diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb
index d195f54be11ff66428c427becbebf075a719de21..27cf66629fea312d11b6f769a6e52910257033a8 100644
--- a/spec/requests/api/issues/issues_spec.rb
+++ b/spec/requests/api/issues/issues_spec.rb
@@ -216,6 +216,10 @@ describe API::Issues do
         expect_paginated_array_response([issue.id, closed_issue.id])
         expect(json_response.first['title']).to eq(issue.title)
         expect(json_response.last).to have_key('web_url')
+        # Calculating the value of subscribed field triggers Markdown
+        # processing. We can't do that for multiple issues / merge
+        # requests in a single API request.
+        expect(json_response.last).not_to have_key('subscribed')
       end
 
       it 'returns an array of closed issues' do