Client-Side Is a Trap for Large Tables in DataTables
DataTables is client-side by default. For large tables move to serverSide:true with a whitelisted, parameterized endpoint that echoes draw and returns recordsTotal, recordsFiltered and data.
04 Jan 2026, 10:07 UTC

You ship a DataTables grid for an internal list. With a few hundred rows it feels instant: paging, sorting and search all happen in the browser. When the list grows to tens of thousands of rows the same grid stalls on load, scrolls become janky, and the browser memory climbs. The practical decision is not whether DataTables can handle it, but where the work should live.
DataTables runs client-side by default. It loads all rows up front and performs paging, ordering and search in the DOM. That is simple and feels fast for small data. The supported escape hatch is server-side processing: initialize with serverSide: true and an ajax source. The browser then renders only the current page and delegates every draw to your endpoint.
What changes with serverSide:true
With server-side processing enabled DataTables sends a stable request shape for each draw. Each request carries draw, start, length, search[value], order[i][column] and order[i][dir], plus per-column metadata such as columns[i][data], name, searchable and orderable.
The server must reply with JSON containing draw, recordsTotal, recordsFiltered and data. The draw value must be echoed unchanged. DataTables discards responses whose draw does not match the outstanding request, which prevents stale or replayed responses from being rendered.
The core server work per draw is two queries: an unfiltered count for recordsTotal, a filtered count for recordsFiltered, and a paged select using LIMIT/OFFSET with ORDER BY. The ordering must be assembled from a server-side whitelist that maps the requested column index to a real, allowed database column. Never build SQL from client-supplied identifiers.
A whitelisted endpoint pattern
Client init:
$('#items').DataTable({
serverSide: true,
ajax: '/api/items',
processing: true,
pageLength: 25,
lengthMenu: [25, 50, 100],
columns: [
{ data: 'id' },
{ data: 'name' },
{ data: 'created_at' }
]
});Server sketch in any stack. Run this in the process that owns read access to the data store. Required permissions are read-only select on the table. Meaningful placeholders: TABLE, ALLOWED_COLUMNS, ALLOWED_LENGTHS.
// parse request
const draw = Number(req.query.draw);
const start = Number(req.query.start);
const length = Number(req.query.length);
const search = req.query.search?.value || '';
const orderColIdx = Number(req.query.order?.[0]?.column);
const orderDir = req.query.order?.[0]?.dir === 'desc' ? 'DESC' : 'ASC';
// validate paging
const allowedLengths = [25,50,100];
const pageSize = allowedLengths.includes(length) ? length : 25;
// whitelist columns
const ALLOWED_COLUMNS = { '0':'id','1':'name','2':'created_at' };
const orderColumn = ALLOWED_COLUMNS[orderColIdx] || 'id';
// build filtered query with parameterized search
// apply search as parameterized LIKE across whitelisted searchable columns
// apply ORDER BY using whitelisted column name and validated direction
// apply LIMIT pageSize OFFSET start
// return
res.json({
draw,
recordsTotal: totalCount,
recordsFiltered: filteredCount,
data: rows
});Expected checks: the Network tab shows draw, start, length sent and the JSON returned echoes the same draw with recordsTotal and recordsFiltered. Tampering order[i][column] with an out-of-range index should fall back to the default whitelisted column, not error or leak schema.
Risk: building SQL by concatenating columns[i][data] or order[i][column] enables injection. Use a whitelist for identifiers and parameterization for values. DataTables 1.10+ uses the modern parameter names; legacy names like sEcho and iDisplayStart belong to the old 1.9 API.
Trade-offs and practical limits
Every interaction becomes a network round trip. The instant client-side feel is replaced by a processing indicator. Enable processing: true so users see feedback during draws. The searchDelay option can be tuned to avoid a request on every keystroke in server-side mode.
recordsFiltered costs an extra COUNT on large tables. Caching counts or capping page sizes keeps the endpoint cheap under load. Regex search and per-column search need explicit server support; they are not automatic.
Features that operate on all rows, such as export extensions and bulk selection helpers, only see the rows DataTables has. In server-side mode that is the current page unless you add a separate export endpoint.
Check the result by loading a page, changing sort, applying a global search, and confirming the returned recordsFiltered matches the visible rows and that the echoed draw matches the request. If you replay or delay a response, DataTables should ignore it when the draw mismatches.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.