diff --git a/Gemfile.lock b/Gemfile.lock
index 90e5d4f3402..204d73d9e4c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -523,6 +523,7 @@ GEM
 PLATFORMS
   aarch64-linux
   arm64-darwin-20
+  arm64-darwin-21
   arm64-darwin-22
   x86_64-darwin-18
   x86_64-darwin-19
diff --git a/app/assets/javascripts/discourse/app/controllers/user-notifications.gjs b/app/assets/javascripts/discourse/app/controllers/user-notifications.gjs
index aef0a45b3eb..31bb62218ac 100644
--- a/app/assets/javascripts/discourse/app/controllers/user-notifications.gjs
+++ b/app/assets/javascripts/discourse/app/controllers/user-notifications.gjs
@@ -11,6 +11,11 @@ import { iconHTML } from "discourse-common/lib/icon-library";
 import discourseComputed from "discourse-common/utils/decorators";
 import I18n from "discourse-i18n";
 
+const _beforeLoadMoreCallbacks = [];
+export function addBeforeLoadMoreCallback(fn) {
+  _beforeLoadMoreCallbacks.push(fn);
+}
+
 export default class UserNotificationsController extends Controller {
   @service modal;
   @service appEvents;
@@ -102,6 +107,14 @@ export default class UserNotificationsController extends Controller {
 
   @action
   loadMore() {
+    if (
+      _beforeLoadMoreCallbacks.length &&
+      !_beforeLoadMoreCallbacks.some((fn) => fn(this))
+    ) {
+      // Return early if any callbacks return false, short-circuiting the default loading more logic
+      return;
+    }
+
     this.model.loadMore();
   }
 }
diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js
index 41d6acc9e92..377254ebf07 100644
--- a/app/assets/javascripts/discourse/app/lib/plugin-api.js
+++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js
@@ -31,6 +31,7 @@ import { addUserMenuProfileTabItem } from "discourse/components/user-menu/profil
 import { addDiscoveryQueryParam } from "discourse/controllers/discovery/list";
 import { registerFullPageSearchType } from "discourse/controllers/full-page-search";
 import { registerCustomPostMessageCallback as registerCustomPostMessageCallback1 } from "discourse/controllers/topic";
+import { addBeforeLoadMoreCallback as addBeforeLoadMoreNotificationsCallback } from "discourse/controllers/user-notifications";
 import { registerCustomUserNavMessagesDropdownRow } from "discourse/controllers/user-private-messages";
 import {
   addExtraIconRenderer,
@@ -88,6 +89,7 @@ import {
   addSaveableUserOptionField,
 } from "discourse/models/user";
 import { setNewCategoryDefaultColors } from "discourse/routes/new-category";
+import { setNotificationsLimit } from "discourse/routes/user-notifications";
 import { addComposerSaveErrorCallback } from "discourse/services/composer";
 import {
   addToHeaderIcons,
@@ -141,7 +143,7 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api";
 // docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
 // using the format described at https://keepachangelog.com/en/1.0.0/.
 
-export const PLUGIN_API_VERSION = "1.18.0";
+export const PLUGIN_API_VERSION = "1.19.0";
 
 // This helper prevents us from applying the same `modifyClass` over and over in test mode.
 function canModify(klass, type, resolverName, changes) {
@@ -819,6 +821,21 @@ class PluginApi {
     registerCustomPostMessageCallback1(type, callback);
   }
 
+  /**
+   * Registers a callback that will be evaluated when infinite scrolling would cause
+   * more notifications to be loaded. This can be used to prevent loading more unless
+   * a specific condition is met.
+   *
+   * Example:
+   *
+   * api.addBeforeLoadMoreNotificationsCallback((controller) => {
+   *   return controller.allowLoadMore;
+   * });
+   */
+  addBeforeLoadMoreNotificationsCallback(fn) {
+    addBeforeLoadMoreNotificationsCallback(fn);
+  }
+
   /**
    * Changes a setting associated with a widget. For example, if
    * you wanted small avatars in the post stream:
@@ -1773,6 +1790,18 @@ class PluginApi {
     setNewCategoryDefaultColors(backgroundColor, textColor);
   }
 
+  /**
+   * Change the number of notifications that are loaded at /my/notifications
+   *
+   * ```
+   * api.setNotificationsLimit(20)
+   * ```
+   *
+   **/
+  setNotificationsLimit(limit) {
+    setNotificationsLimit(limit);
+  }
+
   /**
    * Add a callback to modify search results before displaying them.
    *
diff --git a/app/assets/javascripts/discourse/app/routes/user-notifications.js b/app/assets/javascripts/discourse/app/routes/user-notifications.js
index a1617b2a70a..7b0d4c32a6c 100644
--- a/app/assets/javascripts/discourse/app/routes/user-notifications.js
+++ b/app/assets/javascripts/discourse/app/routes/user-notifications.js
@@ -2,6 +2,13 @@ import ViewingActionType from "discourse/mixins/viewing-action-type";
 import DiscourseRoute from "discourse/routes/discourse";
 import I18n from "discourse-i18n";
 
+const DEFAULT_LIMIT = 60;
+let limit = DEFAULT_LIMIT;
+
+export function setNotificationsLimit(newLimit) {
+  limit = newLimit;
+}
+
 export default DiscourseRoute.extend(ViewingActionType, {
   controllerName: "user-notifications",
   queryParams: { filter: { refreshModel: true } },
@@ -16,6 +23,7 @@ export default DiscourseRoute.extend(ViewingActionType, {
       return this.store.find("notification", {
         username,
         filter: params.filter,
+        limit,
       });
     }
   },
diff --git a/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs b/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs
index a70cd4372fa..b7f8537ee50 100644
--- a/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs
+++ b/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs
@@ -27,6 +27,10 @@
         <UserMenu::MenuItem @item={{item}} />
       {{/each}}
       <ConditionalLoadingSpinner @condition={{this.loading}} />
+      <PluginOutlet
+        @name="user-notifications-list-bottom"
+        @outletArgs={{hash controller=this}}
+      />
     </div>
   {{/if}}
 {{/if}}
\ No newline at end of file
diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md
index 91470ac0c92..4bd3a041e25 100644
--- a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md
+++ b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md
@@ -7,6 +7,14 @@ in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [1.19.0] - 2023-12-13
+
+### Added
+
+- Added `setNotificationsLimit` function, which sets a new limit for how many notifications are loaded for the user notifications route
+
+- Added `addBeforeLoadMoreNotificationsCallback` function, which takes a function as the argument. All added callbacks are evaluated before `loadMore` is triggered for user notifications. If any callback returns false, notifications will not be loaded.
+
 ## [1.18.0] - 2023-12-1
 
 ### Added
diff --git a/spec/system/page_objects/pages/user.rb b/spec/system/page_objects/pages/user.rb
index 5888015d0bc..f5021a238f0 100644
--- a/spec/system/page_objects/pages/user.rb
+++ b/spec/system/page_objects/pages/user.rb
@@ -52,6 +52,10 @@ module PageObjects
         self
       end
 
+      def click_primary_navigation_item(name)
+        page.find(primary_navigation_selector(name)).click
+      end
+
       private
 
       def primary_navigation_selector(name)
diff --git a/spec/system/page_objects/pages/user_notifications.rb b/spec/system/page_objects/pages/user_notifications.rb
index 4500c05ff0f..574541432bc 100644
--- a/spec/system/page_objects/pages/user_notifications.rb
+++ b/spec/system/page_objects/pages/user_notifications.rb
@@ -27,6 +27,10 @@ module PageObjects
       def has_no_notification?(notification)
         page.has_no_css?(".notification a[href='#{notification.url}']")
       end
+
+      def has_notification_count_of?(count)
+        page.has_css?(".notification", count: count)
+      end
     end
   end
 end
diff --git a/spec/system/user_page/user_notifications_spec.rb b/spec/system/user_page/user_notifications_spec.rb
index 4a2d5d59be0..3fff7dfaa1a 100644
--- a/spec/system/user_page/user_notifications_spec.rb
+++ b/spec/system/user_page/user_notifications_spec.rb
@@ -3,6 +3,7 @@
 describe "User notifications", type: :system do
   fab!(:user)
   let(:user_notifications_page) { PageObjects::Pages::UserNotifications.new }
+  let(:user_page) { PageObjects::Pages::User.new }
 
   fab!(:read_notification) { Fabricate(:notification, user: user, read: true) }
   fab!(:unread_notification) { Fabricate(:notification, user: user, read: false) }
@@ -10,7 +11,7 @@ describe "User notifications", type: :system do
   before { sign_in(user) }
 
   describe "filtering" do
-    it "saves custom picture and system assigned pictures" do
+    it "correctly filters all / read / unread notifications" do
       user_notifications_page.visit(user)
       user_notifications_page.filter_dropdown
       expect(user_notifications_page).to have_selected_filter_value("all")
@@ -28,4 +29,26 @@ describe "User notifications", type: :system do
       expect(user_notifications_page).to have_notification(unread_notification)
     end
   end
+
+  describe "setNotificationLimit & addBeforeLoadMoreNotificationsCallback plugin-api functions" do
+    it "Allows blocking loading via callback and limit" do
+      user_page.visit(user)
+
+      page.execute_script <<~JS
+        require("discourse/lib/plugin-api").withPluginApi("1.19.0", (api) => {
+          api.setNotificationsLimit(1);
+
+          api.addBeforeLoadMoreNotificationsCallback(() => {
+            return false;
+          })
+        })
+      JS
+
+      user_page.click_primary_navigation_item("notifications")
+
+      # It is 1 here because we blocked infinite scrolling. Even though the limit is 1,
+      # without the callback, we would have 2 items here as it immediately fires another request.
+      expect(user_notifications_page).to have_notification_count_of(1)
+    end
+  end
 end