diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 1374154868728fa21f04e8088a2e23f7eacdb4c7..e30935be4b6b96c3875517defadfc3525858c72b 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -36,6 +36,15 @@ class SearchController < ApplicationController
     check_single_commit_result
   end
 
+  def count
+    params.require([:search, :scope])
+
+    scope = search_service.scope
+    count = search_service.search_results.formatted_count(scope)
+
+    render json: { count: count }
+  end
+
   # rubocop: disable CodeReuse/ActiveRecord
   def autocomplete
     term = params[:term]
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index f5c4686a3bf6ebbfa0cf2da42a308cbc9dd9aeb1..add4e555c7096f706a37221b8fd9301ef200960a 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -145,17 +145,28 @@ module SearchHelper
     Sanitize.clean(str)
   end
 
-  def search_filter_path(options = {})
-    exist_opts = {
-      search: params[:search],
-      project_id: params[:project_id],
-      group_id: params[:group_id],
-      scope: params[:scope],
-      repository_ref: params[:repository_ref]
-    }
+  def search_filter_link(scope, label, data: {}, search: {})
+    search_params = params
+      .merge(search)
+      .merge({ scope: scope })
+      .permit(:search, :scope, :project_id, :group_id, :repository_ref, :snippets)
+
+    if @scope == scope
+      li_class = 'active'
+      count = @search_results.formatted_count(scope)
+    else
+      count = 0
+      badge_class = 'js-search-count'
+      badge_data = { scope: scope, url: search_count_path(search_params) }
+    end
 
-    options = exist_opts.merge(options)
-    search_path(options)
+    content_tag :li, class: li_class, data: data do
+      link_to search_path(search_params) do
+        concat label
+        concat ' '
+        concat content_tag(:span, count, class: 'badge badge-pill', data: { scope: scope })
+      end
+    end
   end
 
   def search_filter_input_options(type)
@@ -212,10 +223,6 @@ module SearchHelper
     sanitize(html, tags: %w(a p ol ul li pre code))
   end
 
-  def limited_count(count, limit = 1000)
-    count > limit ? "#{limit}+" : count
-  end
-
   def search_tabs?(tab)
     return false if Feature.disabled?(:users_search, default_enabled: true)
 
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index bc900992cb0dbdbb86a3dba51054e2cb522940b7..e6a235e39da29196270cb97218005340b8e2b8af 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -47,5 +47,6 @@
       = hidden_field_tag :snippets, true
     = hidden_field_tag :repository_ref, @ref
     = hidden_field_tag :nav_source, 'navbar'
+    -# workaround for non-JS feature specs, for JS you need to use find('#search').send_keys(:enter)
     = button_tag 'Go' if ENV['RAILS_ENV'] == 'test'
     .search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path, :'data-autocomplete-project-id' => @project.try(:id), :'data-autocomplete-project-ref' => @ref }
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index 18613ff4c166fe58741ac5982463dc14fc8413d3..eae2a491ceb1f0539d3b320a799564489a65b0cd 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -1,10 +1,6 @@
 - users = capture_haml do
   - if search_tabs?(:members)
-    %li{ class: active_when(@scope == 'users') }
-      = link_to search_filter_path(scope: 'users') do
-        Users
-        %span.badge.badge-pill
-          = limited_count(@search_results.limited_users_count)
+    = search_filter_link 'users', _("Users")
 
 .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
   .fade-left= icon('angle-left')
@@ -12,80 +8,28 @@
   %ul.nav-links.search-filter.scrolling-tabs.nav.nav-tabs
     - if @project
       - if project_search_tabs?(:blobs)
-        %li{ class: active_when(@scope == 'blobs'), data: { qa_selector: 'code_tab' } }
-          = link_to search_filter_path(scope: 'blobs') do
-            = _("Code")
-            %span.badge.badge-pill
-              = @search_results.blobs_count
+        = search_filter_link 'blobs', _("Code"), data: { qa_selector: 'code_tab' }
       - if project_search_tabs?(:issues)
-        %li{ class: active_when(@scope == 'issues') }
-          = link_to search_filter_path(scope: 'issues') do
-            = _("Issues")
-            %span.badge.badge-pill
-              = limited_count(@search_results.limited_issues_count)
+        = search_filter_link 'issues', _("Issues")
       - if project_search_tabs?(:merge_requests)
-        %li{ class: active_when(@scope == 'merge_requests') }
-          = link_to search_filter_path(scope: 'merge_requests') do
-            = _("Merge requests")
-            %span.badge.badge-pill
-              = limited_count(@search_results.limited_merge_requests_count)
+        = search_filter_link 'merge_requests', _("Merge requests")
       - if project_search_tabs?(:milestones)
-        %li{ class: active_when(@scope == 'milestones') }
-          = link_to search_filter_path(scope: 'milestones') do
-            = _("Milestones")
-            %span.badge.badge-pill
-              = limited_count(@search_results.limited_milestones_count)
+        = search_filter_link 'milestones', _("Milestones")
       - if project_search_tabs?(:notes)
-        %li{ class: active_when(@scope == 'notes') }
-          = link_to search_filter_path(scope: 'notes') do
-            = _("Comments")
-            %span.badge.badge-pill
-              = limited_count(@search_results.limited_notes_count)
+        = search_filter_link 'notes', _("Comments")
       - if project_search_tabs?(:wiki)
-        %li{ class: active_when(@scope == 'wiki_blobs') }
-          = link_to search_filter_path(scope: 'wiki_blobs') do
-            = _("Wiki")
-            %span.badge.badge-pill
-              = @search_results.wiki_blobs_count
+        = search_filter_link 'wiki_blobs', _("Wiki")
       - if project_search_tabs?(:commits)
-        %li{ class: active_when(@scope == 'commits') }
-          = link_to search_filter_path(scope: 'commits') do
-            = _("Commits")
-            %span.badge.badge-pill
-              = @search_results.commits_count
+        = search_filter_link 'commits', _("Commits")
       = users
 
     - elsif @show_snippets
-      %li{ class: active_when(@scope == 'snippet_blobs') }
-        = link_to search_filter_path(scope: 'snippet_blobs', snippets: true, group_id: nil, project_id: nil) do
-          = _("Snippet Contents")
-          %span.badge.badge-pill
-            = @search_results.snippet_blobs_count
-      %li{ class: active_when(@scope == 'snippet_titles') }
-        = link_to search_filter_path(scope: 'snippet_titles', snippets: true, group_id: nil, project_id: nil) do
-          = _("Titles and Filenames")
-          %span.badge.badge-pill
-            = @search_results.snippet_titles_count
+      = search_filter_link 'snippet_blobs', _("Snippet Contents"), search: { snippets: true, group_id: nil, project_id: nil }
+      = search_filter_link 'snippet_titles', _("Titles and Filenames"), search: { snippets: true, group_id: nil, project_id: nil }
     - else
-      %li{ class: active_when(@scope == 'projects') }
-        = link_to search_filter_path(scope: 'projects') do
-          = _("Projects")
-          %span.badge.badge-pill
-            = limited_count(@search_results.limited_projects_count)
-      %li{ class: active_when(@scope == 'issues') }
-        = link_to search_filter_path(scope: 'issues') do
-          = _("Issues")
-          %span.badge.badge-pill
-            = limited_count(@search_results.limited_issues_count)
-      %li{ class: active_when(@scope == 'merge_requests') }
-        = link_to search_filter_path(scope: 'merge_requests') do
-          = _("Merge requests")
-          %span.badge.badge-pill
-            = limited_count(@search_results.limited_merge_requests_count)
-      %li{ class: active_when(@scope == 'milestones') }
-        = link_to search_filter_path(scope: 'milestones') do
-          = _("Milestones")
-          %span.badge.badge-pill
-            = limited_count(@search_results.limited_milestones_count)
+      = search_filter_link 'projects', _("Projects")
+      = search_filter_link 'issues', _("Issues")
+      = search_filter_link 'merge_requests', _("Merge requests")
+      = search_filter_link 'milestones', _("Milestones")
       = render_if_exists 'search/category_elasticsearch'
       = users
diff --git a/changelogs/unreleased/load-search-counts-async.yml b/changelogs/unreleased/load-search-counts-async.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1f466450e7688c2125475f93599b90e730d43a41
--- /dev/null
+++ b/changelogs/unreleased/load-search-counts-async.yml
@@ -0,0 +1,5 @@
+---
+title: Load search result counts asynchronously
+merge_request: 31663
+author:
+type: changed
diff --git a/config/routes.rb b/config/routes.rb
index 459f2b22bf032af8b9fbbb527de25fbf6dc1642a..fdef31429f323be866c1961f50138f6ad229322a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -58,6 +58,7 @@ Rails.application.routes.draw do
   # Search
   get 'search' => 'search#show'
   get 'search/autocomplete' => 'search#autocomplete', as: :search_autocomplete
+  get 'search/count' => 'search#count', as: :search_count
 
   # JSON Web Token
   get 'jwt/auth' => 'jwt#auth'
diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb
index 5e77d31760d2ebac4839250955e793e8ba58cdfc..2669adb8455cc9d17de5df05bb92f2436e96f90c 100644
--- a/lib/gitlab/project_search_results.rb
+++ b/lib/gitlab/project_search_results.rb
@@ -29,6 +29,21 @@ module Gitlab
       end
     end
 
+    def formatted_count(scope)
+      case scope
+      when 'blobs'
+        blobs_count.to_s
+      when 'notes'
+        formatted_limited_count(limited_notes_count)
+      when 'wiki_blobs'
+        wiki_blobs_count.to_s
+      when 'commits'
+        commits_count.to_s
+      else
+        super
+      end
+    end
+
     def users
       super.where(id: @project.team.members) # rubocop:disable CodeReuse/ActiveRecord
     end
diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb
index 7c1e6b1baff36ea799df7fa5a2c2fe9ca48b23a1..ce4c1611687fdb9154061ffb83b5328c748bf59b 100644
--- a/lib/gitlab/search_results.rb
+++ b/lib/gitlab/search_results.rb
@@ -43,6 +43,29 @@ module Gitlab
       without_count ? collection.without_count : collection
     end
 
+    def formatted_count(scope)
+      case scope
+      when 'projects'
+        formatted_limited_count(limited_projects_count)
+      when 'issues'
+        formatted_limited_count(limited_issues_count)
+      when 'merge_requests'
+        formatted_limited_count(limited_merge_requests_count)
+      when 'milestones'
+        formatted_limited_count(limited_milestones_count)
+      when 'users'
+        formatted_limited_count(limited_users_count)
+      end
+    end
+
+    def formatted_limited_count(count)
+      if count >= COUNT_LIMIT
+        "#{COUNT_LIMIT - 1}+"
+      else
+        count.to_s
+      end
+    end
+
     def limited_projects_count
       @limited_projects_count ||= limited_count(projects)
     end
diff --git a/lib/gitlab/snippet_search_results.rb b/lib/gitlab/snippet_search_results.rb
index e360b552f89d366ea671023b0f54c803b7d6dd32..ac3b219e0c7320f9fdaa98aa8b81656f4f235c78 100644
--- a/lib/gitlab/snippet_search_results.rb
+++ b/lib/gitlab/snippet_search_results.rb
@@ -22,6 +22,17 @@ module Gitlab
       end
     end
 
+    def formatted_count(scope)
+      case scope
+      when 'snippet_titles'
+        snippet_titles_count.to_s
+      when 'snippet_blobs'
+        snippet_blobs_count.to_s
+      else
+        super
+      end
+    end
+
     def snippet_titles_count
       @snippet_titles_count ||= snippet_titles.count
     end
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index 5a5c0a1f6ac7bc2f4f0a24277bd512a04461c6f2..3e0d53a6573d6c7a77a2d85480550f3130403b06 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -11,151 +11,173 @@ describe SearchController do
     sign_in(user)
   end
 
-  context 'uses the right partials depending on scope' do
-    using RSpec::Parameterized::TableSyntax
-    render_views
-
-    set(:project) { create(:project, :public, :repository, :wiki_repo) }
-
+  shared_examples_for 'when the user cannot read cross project' do |action, params|
     before do
-      expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original
+      allow(Ability).to receive(:allowed?).and_call_original
+      allow(Ability).to receive(:allowed?)
+                          .with(user, :read_cross_project, :global) { false }
     end
 
-    subject { get(:show, params: { project_id: project.id, scope: scope, search: 'merge' }) }
+    it 'blocks access without a project_id' do
+      get action, params: params
 
-    where(:partial, :scope) do
-      '_blob'        | :blobs
-      '_wiki_blob'   | :wiki_blobs
-      '_commit'      | :commits
+      expect(response).to have_gitlab_http_status(403)
     end
 
-    with_them do
-      it do
-        project_wiki = create(:project_wiki, project: project, user: user)
-        create(:wiki_page, wiki: project_wiki, attrs: { title: 'merge', content: 'merge' })
+    it 'allows access with a project_id' do
+      get action, params: params.merge(project_id: create(:project, :public).id)
 
-        expect(subject).to render_template("search/results/#{partial}")
-      end
+      expect(response).to have_gitlab_http_status(200)
     end
   end
 
-  context 'global search' do
-    render_views
-
-    it 'omits pipeline status from load' do
-      project = create(:project, :public)
-      expect(Gitlab::Cache::Ci::ProjectPipelineStatus).not_to receive(:load_in_batch_for_projects)
-
-      get :show, params: { scope: 'projects', search: project.name }
+  shared_examples_for 'with external authorization service enabled' do |action, params|
+    let(:project) { create(:project, namespace: user.namespace) }
+    let(:note) { create(:note_on_issue, project: project) }
 
-      expect(assigns[:search_objects].first).to eq project
+    before do
+      enable_external_authorization_service_check
     end
-  end
-
-  it 'finds issue comments' do
-    project = create(:project, :public)
-    note = create(:note_on_issue, project: project)
 
-    get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
+    it 'renders a 403 when no project is given' do
+      get action, params: params
 
-    expect(assigns[:search_objects].first).to eq note
-  end
-
-  context 'when the user cannot read cross project' do
-    before do
-      allow(Ability).to receive(:allowed?).and_call_original
-      allow(Ability).to receive(:allowed?)
-                          .with(user, :read_cross_project, :global) { false }
+      expect(response).to have_gitlab_http_status(403)
     end
 
-    it 'still allows accessing the search page' do
-      get :show
+    it 'renders a 200 when a project was set' do
+      get action, params: params.merge(project_id: project.id)
 
       expect(response).to have_gitlab_http_status(200)
     end
+  end
 
-    it 'still blocks searches without a project_id' do
-      get :show, params: { search: 'hello' }
+  describe 'GET #show' do
+    it_behaves_like 'when the user cannot read cross project', :show, { search: 'hello' } do
+      it 'still allows accessing the search page' do
+        get :show
 
-      expect(response).to have_gitlab_http_status(403)
+        expect(response).to have_gitlab_http_status(200)
+      end
     end
 
-    it 'allows searches with a project_id' do
-      get :show, params: { search: 'hello', project_id: create(:project, :public).id }
+    it_behaves_like 'with external authorization service enabled', :show, { search: 'hello' }
 
-      expect(response).to have_gitlab_http_status(200)
-    end
-  end
+    context 'uses the right partials depending on scope' do
+      using RSpec::Parameterized::TableSyntax
+      render_views
+
+      set(:project) { create(:project, :public, :repository, :wiki_repo) }
 
-  context 'on restricted projects' do
-    context 'when signed out' do
       before do
-        sign_out(user)
+        expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original
       end
 
-      it "doesn't expose comments on issues" do
-        project = create(:project, :public, :issues_private)
-        note = create(:note_on_issue, project: project)
+      subject { get(:show, params: { project_id: project.id, scope: scope, search: 'merge' }) }
 
-        get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
+      where(:partial, :scope) do
+        '_blob'        | :blobs
+        '_wiki_blob'   | :wiki_blobs
+        '_commit'      | :commits
+      end
 
-        expect(assigns[:search_objects].count).to eq(0)
+      with_them do
+        it do
+          project_wiki = create(:project_wiki, project: project, user: user)
+          create(:wiki_page, wiki: project_wiki, attrs: { title: 'merge', content: 'merge' })
+
+          expect(subject).to render_template("search/results/#{partial}")
+        end
       end
     end
 
-    it "doesn't expose comments on merge_requests" do
-      project = create(:project, :public, :merge_requests_private)
-      note = create(:note_on_merge_request, project: project)
+    context 'global search' do
+      render_views
 
-      get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
+      it 'omits pipeline status from load' do
+        project = create(:project, :public)
+        expect(Gitlab::Cache::Ci::ProjectPipelineStatus).not_to receive(:load_in_batch_for_projects)
+
+        get :show, params: { scope: 'projects', search: project.name }
 
-      expect(assigns[:search_objects].count).to eq(0)
+        expect(assigns[:search_objects].first).to eq project
+      end
     end
 
-    it "doesn't expose comments on snippets" do
-      project = create(:project, :public, :snippets_private)
-      note = create(:note_on_project_snippet, project: project)
+    it 'finds issue comments' do
+      project = create(:project, :public)
+      note = create(:note_on_issue, project: project)
 
       get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
 
-      expect(assigns[:search_objects].count).to eq(0)
+      expect(assigns[:search_objects].first).to eq note
     end
-  end
 
-  context 'with external authorization service enabled' do
-    let(:project) { create(:project, namespace: user.namespace) }
-    let(:note) { create(:note_on_issue, project: project) }
+    context 'on restricted projects' do
+      context 'when signed out' do
+        before do
+          sign_out(user)
+        end
 
-    before do
-      enable_external_authorization_service_check
-    end
+        it "doesn't expose comments on issues" do
+          project = create(:project, :public, :issues_private)
+          note = create(:note_on_issue, project: project)
 
-    describe 'GET #show' do
-      it 'renders a 403 when no project is given' do
-        get :show, params: { scope: 'notes', search: note.note }
+          get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
 
-        expect(response).to have_gitlab_http_status(403)
+          expect(assigns[:search_objects].count).to eq(0)
+        end
       end
 
-      it 'renders a 200 when a project was set' do
+      it "doesn't expose comments on merge_requests" do
+        project = create(:project, :public, :merge_requests_private)
+        note = create(:note_on_merge_request, project: project)
+
         get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
 
-        expect(response).to have_gitlab_http_status(200)
+        expect(assigns[:search_objects].count).to eq(0)
       end
-    end
 
-    describe 'GET #autocomplete' do
-      it 'renders a 403 when no project is given' do
-        get :autocomplete, params: { term: 'hello' }
+      it "doesn't expose comments on snippets" do
+        project = create(:project, :public, :snippets_private)
+        note = create(:note_on_project_snippet, project: project)
 
-        expect(response).to have_gitlab_http_status(403)
+        get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
+
+        expect(assigns[:search_objects].count).to eq(0)
       end
+    end
+  end
 
-      it 'renders a 200 when a project was set' do
-        get :autocomplete, params: { project_id: project.id, term: 'hello' }
+  describe 'GET #count' do
+    it_behaves_like 'when the user cannot read cross project', :count, { search: 'hello', scope: 'projects' }
+    it_behaves_like 'with external authorization service enabled', :count, { search: 'hello', scope: 'projects' }
 
-        expect(response).to have_gitlab_http_status(200)
-      end
+    it 'returns the result count for the given term and scope' do
+      create(:project, :public, name: 'hello world')
+      create(:project, :public, name: 'foo bar')
+
+      get :count, params: { search: 'hello', scope: 'projects' }
+
+      expect(response).to have_gitlab_http_status(200)
+      expect(json_response).to eq({ 'count' => '1' })
+    end
+
+    it 'raises an error if search term is missing' do
+      expect do
+        get :count, params: { scope: 'projects' }
+      end.to raise_error(ActionController::ParameterMissing)
     end
+
+    it 'raises an error if search scope is missing' do
+      expect do
+        get :count, params: { search: 'hello' }
+      end.to raise_error(ActionController::ParameterMissing)
+    end
+  end
+
+  describe 'GET #autocomplete' do
+    it_behaves_like 'when the user cannot read cross project', :autocomplete, { term: 'hello' }
+    it_behaves_like 'with external authorization service enabled', :autocomplete, { term: 'hello' }
   end
 end
diff --git a/spec/features/search/user_searches_for_users_spec.rb b/spec/features/search/user_searches_for_users_spec.rb
index 2517a843c62503f23bca166c945df9fc80be97f9..e10c1afc0b8f305aa7423120fe0ac22aca4ce76d 100644
--- a/spec/features/search/user_searches_for_users_spec.rb
+++ b/spec/features/search/user_searches_for_users_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
 
 describe 'User searches for users' do
   context 'when on the dashboard' do
-    it 'finds the user' do
+    it 'finds the user', :js do
       create(:user, username: 'gob_bluth', name: 'Gob Bluth')
 
       sign_in(create(:user))
@@ -12,7 +12,7 @@ describe 'User searches for users' do
       visit dashboard_projects_path
 
       fill_in 'search', with: 'gob'
-      click_button 'Go'
+      find('#search').send_keys(:enter)
 
       expect(page).to have_content('Users 1')
 
diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb
index c781048d06dfbdc94357798029d6856c1c6393f9..5006631cc148b266a7a87e1c42096a3672224204 100644
--- a/spec/features/search/user_uses_header_search_field_spec.rb
+++ b/spec/features/search/user_uses_header_search_field_spec.rb
@@ -96,6 +96,23 @@ describe 'User uses header search field', :js do
       let(:url) { root_path }
       let(:scope_name) { 'All GitLab' }
     end
+
+    context 'when searching through the search field' do
+      before do
+        create(:issue, project: project, title: 'project issue')
+
+        fill_in('search', with: 'project')
+        find('#search').send_keys(:enter)
+      end
+
+      it 'displays result counts for all categories' do
+        expect(page).to have_content('Projects 1')
+        expect(page).to have_content('Issues 1')
+        expect(page).to have_content('Merge requests 0')
+        expect(page).to have_content('Milestones 0')
+        expect(page).to have_content('Users 0')
+      end
+    end
   end
 
   context 'when user is in a project scope' do
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index c69493b579fd1b706dc91b354fd5af5e7f3b8aee..2ab72679ee7bee3a9f1f2f52375cb5ea9ab8d74b 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -177,4 +177,48 @@ describe SearchHelper do
       end
     end
   end
+
+  describe 'search_filter_link' do
+    it 'renders a search filter link for the current scope' do
+      @scope = 'projects'
+      @search_results = double
+
+      expect(@search_results).to receive(:formatted_count).with('projects').and_return('23')
+
+      link = search_filter_link('projects', 'Projects')
+
+      expect(link).to have_css('li.active')
+      expect(link).to have_link('Projects', href: search_path(scope: 'projects'))
+      expect(link).to have_css('span.badge.badge-pill:not(.js-search-count):not(.hidden):not([data-url])', text: '23')
+    end
+
+    it 'renders a search filter link for another scope' do
+      link = search_filter_link('projects', 'Projects')
+      count_path = search_count_path(scope: 'projects')
+
+      expect(link).to have_css('li:not([class="active"])')
+      expect(link).to have_link('Projects', href: search_path(scope: 'projects'))
+      expect(link).to have_css("span.badge.badge-pill.js-search-count.hidden[data-url='#{count_path}']", text: '')
+    end
+
+    it 'merges in the current search params and given params' do
+      expect(self).to receive(:params).and_return(
+        ActionController::Parameters.new(
+          search: 'hello',
+          scope: 'ignored',
+          other_param: 'ignored'
+        )
+      )
+
+      link = search_filter_link('projects', 'Projects', search: { project_id: 23 })
+
+      expect(link).to have_link('Projects', href: search_path(scope: 'projects', search: 'hello', project_id: 23))
+    end
+
+    it 'assigns given data attributes on the list container' do
+      link = search_filter_link('projects', 'Projects', data: { foo: 'bar' })
+
+      expect(link).to have_css('li[data-foo="bar"]')
+    end
+  end
 end
diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb
index 4a41d5cf51e8e911a7e1892478070e06ba8154ea..c7462500c82fff84299c92be8d29eea18cbfb16b 100644
--- a/spec/lib/gitlab/project_search_results_spec.rb
+++ b/spec/lib/gitlab/project_search_results_spec.rb
@@ -22,6 +22,28 @@ describe Gitlab::ProjectSearchResults do
     it { expect(results.query).to eq('hello world') }
   end
 
+  describe '#formatted_count' do
+    using RSpec::Parameterized::TableSyntax
+
+    let(:results) { described_class.new(user, project, query) }
+
+    where(:scope, :count_method, :expected) do
+      'blobs'      | :blobs_count            | '1234'
+      'notes'      | :limited_notes_count    | '1000+'
+      'wiki_blobs' | :wiki_blobs_count       | '1234'
+      'commits'    | :commits_count          | '1234'
+      'projects'   | :limited_projects_count | '1000+'
+      'unknown'    | nil                     | nil
+    end
+
+    with_them do
+      it 'returns the expected formatted count' do
+        expect(results).to receive(count_method).and_return(1234) if count_method
+        expect(results.formatted_count(scope)).to eq(expected)
+      end
+    end
+  end
+
   shared_examples 'general blob search' do |entity_type, blob_kind|
     let(:query) { 'files' }
     subject(:results) { described_class.new(user, project, query).objects(blob_type) }
diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb
index 3d27156b3561753133515e4192a7e8e5754e74ac..c287da193432c18ba32bb2f23a079f8be67dc85c 100644
--- a/spec/lib/gitlab/search_results_spec.rb
+++ b/spec/lib/gitlab/search_results_spec.rb
@@ -29,6 +29,43 @@ describe Gitlab::SearchResults do
       end
     end
 
+    describe '#formatted_count' do
+      using RSpec::Parameterized::TableSyntax
+
+      where(:scope, :count_method, :expected) do
+        'projects'       | :limited_projects_count       | '1000+'
+        'issues'         | :limited_issues_count         | '1000+'
+        'merge_requests' | :limited_merge_requests_count | '1000+'
+        'milestones'     | :limited_milestones_count     | '1000+'
+        'users'          | :limited_users_count          | '1000+'
+        'unknown'        | nil                           | nil
+      end
+
+      with_them do
+        it 'returns the expected formatted count' do
+          expect(results).to receive(count_method).and_return(1234) if count_method
+          expect(results.formatted_count(scope)).to eq(expected)
+        end
+      end
+    end
+
+    describe '#formatted_limited_count' do
+      using RSpec::Parameterized::TableSyntax
+
+      where(:count, :expected) do
+        23   | '23'
+        1000 | '1000'
+        1001 | '1000+'
+        1234 | '1000+'
+      end
+
+      with_them do
+        it 'returns the expected formatted limited count' do
+          expect(results.formatted_limited_count(count)).to eq(expected)
+        end
+      end
+    end
+
     context "when count_limit is lower than total amount" do
       before do
         allow(results).to receive(:count_limit).and_return(1)
diff --git a/spec/lib/gitlab/snippet_search_results_spec.rb b/spec/lib/gitlab/snippet_search_results_spec.rb
index b661a894c0c85a861c80d5d70f91f418303fc5b1..35df38f052ba55e791556eb0dc9e1da050407d90 100644
--- a/spec/lib/gitlab/snippet_search_results_spec.rb
+++ b/spec/lib/gitlab/snippet_search_results_spec.rb
@@ -16,4 +16,22 @@ describe Gitlab::SnippetSearchResults do
       expect(results.snippet_blobs_count).to eq(1)
     end
   end
+
+  describe '#formatted_count' do
+    using RSpec::Parameterized::TableSyntax
+
+    where(:scope, :count_method, :expected) do
+      'snippet_titles' | :snippet_titles_count   | '1234'
+      'snippet_blobs'  | :snippet_blobs_count    | '1234'
+      'projects'       | :limited_projects_count | '1000+'
+      'unknown'        | nil                     | nil
+    end
+
+    with_them do
+      it 'returns the expected formatted count' do
+        expect(results).to receive(count_method).and_return(1234) if count_method
+        expect(results.formatted_count(scope)).to eq(expected)
+      end
+    end
+  end
 end