Server-Side Pagination with ng-repeat in AngularJS 1.x: A Practical Guide
Learn how to render large datasets in AngularJS 1.x using ng-repeat with track by and server-side pagination, including controller setup, loading states, and practical verification steps.
06 Feb 2026, 02:33 UTC

The Problem: Large Lists Slow Down the Digest Cycle
When an AngularJS 1.x application renders hundreds or thousands of rows with ng-repeat, every digest cycle evaluates bindings for each DOM node. Replacing the entire array forces AngularJS to tear down and rebuild the list, which blocks the main thread and makes the interface feel sluggish. The practical fix is to request only one page of data at a time from the server and render that smaller slice. This guide walks through wiring ng-repeat to a paginated $http endpoint, adding explicit loading and error states, and verifying that the DOM stays lean.
Prerequisites and Security Context
Before you start, confirm that your application runs on a maintained AngularJS 1.x build. The framework reached end-of-life, and unsupported versions may contain unpatched security vulnerabilities. If you are on an older 1.x line, plan a migration path; the patterns below help you ship a safer interim UI, but they do not make the framework itself secure.
You also need:
- A working AngularJS 1.x module and controller.
- The
$httpservice available for injection. - A server endpoint that accepts paging parameters, such as
pageandpageSize, and returns the current slice plus total count metadata. - If the API lives on a different origin, correct CORS headers on the server and any required authentication tokens attached to the request.
Controller Logic for Paginated Requests
Avoid attaching large data structures directly to $scope. Instead, use the controllerAs syntax and keep state on a view-model object. This reduces the risk of accidental watchers on heavy objects and makes cleanup easier.
angular.module('app').controller('ListController', ListController);
ListController.$inject = ['$http'];
function ListController($http) {
var vm = this;
vm.items = [];
vm.currentPage = 1;
vm.totalPages = 1;
vm.pageSize = 20;
vm.loading = false;
vm.error = null;
vm.loadPage = function(page) {
if (page < 1 || page > vm.totalPages || vm.loading) {
return;
}
vm.loading = true;
vm.error = null;
return $http.get('/api/items', {
params: { page: page, pageSize: vm.pageSize }
}).then(function(response) {
var data = response.data;
vm.items = data.items;
vm.currentPage = data.page;
vm.totalPages = data.totalPages;
}).catch(function(err) {
vm.error = 'Failed to load data. Please try again.';
// Log err for diagnostics; do not expose raw errors to users.
}).finally(function() {
vm.loading = false;
});
};
vm.next = function() {
vm.loadPage(vm.currentPage + 1);
};
vm.previous = function() {
vm.loadPage(vm.currentPage - 1);
};
// Initial load
vm.loadPage(1);
}
The finally callback runs after both success and failure, ensuring the loading flag is always cleared. Because $http promises are integrated with the digest cycle, the view updates automatically when the assignment completes; you do not need to wrap the call in $timeout.
Rendering the Current Page with ng-repeat
In the template, bind the repeated element to the view-model array and supply a track by expression. Without it, AngularJS tracks items by object identity, so replacing the array with a new page of objects destroys every DOM node and creates new ones. A stable identifier lets AngularJS reuse existing rows when the underlying data changes.
<div ng-controller="ListController as vm">
<div ng-if="vm.loading">Loading…</div>
<div ng-if="vm.error">{{ vm.error }}</div>
<ul>
<li ng-repeat="item in vm.items track by item.id">
{{ item.name }}
</li>
</ul>
<button ng-click="vm.previous()" ng-disabled="vm.currentPage <= 1 || vm.loading">Previous</button>
<span>Page {{ vm.currentPage }} of {{ vm.totalPages }}</span>
<button ng-click="vm.next()" ng-disabled="vm.currentPage >= vm.totalPages || vm.loading">Next</button>
</div>
If your records lack a unique id, use a track function that returns a stable key, such as a composite of fields. Never use $index for tracking when the list can be reordered or refetched; $index changes whenever the array is replaced, which defeats the optimization.
Pagination Controls and State Handling
The buttons above are disabled while a request is in flight and at the boundaries of the result set. This prevents duplicate requests and out-of-range calls. If you prefer infinite scroll, attach a scroll listener and call vm.loadPage(vm.currentPage + 1) when the user nears the bottom, but throttle the handler so you do not fire overlapping $http calls.
Error handling should be explicit. The catch block sets a user-facing message, and the retry path is simply calling vm.loadPage(vm.currentPage) again. Keep the error object off the scope; store only a string or a small status object so the digest cycle stays cheap.
Expected Checks and Recovery Options
After wiring the controller and template, verify the behavior in the browser:
- Open the page and inspect the generated DOM with developer tools. Confirm that only the current page’s items are present and that repeated elements are not duplicated.
- Watch the network panel. Each page change should issue a single
GETto/api/itemswith the correctpageandpageSizequery parameters. - Use the browser console to trigger page changes, for example by evaluating
angular.element(document.body).scope().vm.next()if your app attaches the controller to the body scope, or by clicking the controls and confirming that the loading indicator appears during requests and that the list updates without errors. - If the list appears frozen, check that
track byis present and that you are not binding to a function that returns a new object on every digest.
Recovery options:
- Request failures: The error message appears and the Previous/Next buttons remain usable, allowing an immediate retry.
- Duplicate DOM nodes: Remove any
track by $indexand replace it with a stable identifier. - Cross-origin failures: Verify that the server returns
Access-Control-Allow-Originand any requiredAccess-Control-Allow-Headersfor your authentication token. - Memory pressure: If the page grows sluggish over time, audit custom directives for listeners or watchers that are not removed on
$destroy. Do not store large raw responses on the scope; map them to a minimal view model.
Limitations and Migration Considerations
Server-side pagination only helps if the server actually limits the result set. If your endpoint ignores paging parameters and returns every row, the client still pays the rendering cost. Also, very large offsets can be slow on the database side unless the query is properly indexed.
Finally, remember that AngularJS 1.x is no longer receiving official updates. The patterns in this guide are useful for maintaining an existing application, but they should be part of a broader plan to migrate to a supported framework. Until that migration happens, keep your AngularJS build patched to the latest available 1.x release and audit third-party libraries for known issues.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.