Choosing DataTables Server‑Side Processing: When the Browser Can’t Keep Up
DataTables server‑side processing offloads sorting, filtering, and pagination to the backend. This post shows when to switch, the exact AJAX contract, common pitfalls, and a minimal working example with Node/Express.
26 Jun 2026, 11:24 UTC

The problem: a table that freezes the browser
You’ve got a DataTables instance that works fine with a few thousand rows. Then the product team asks for 200 000 records, each with a dozen columns, and the page starts to hang on sort or search. The DOM is bloated, the JavaScript heap spikes, and users see a “processing” spinner that never disappears.
The fix isn’t a bigger laptop—it’s moving the heavy lifting to the server. DataTables’ serverSide: true mode delegates sorting, filtering, and pagination to your backend, sending only the tiny slice the user actually sees.
When to flip the switch
- Row count > 50 k (or query cost is high). Client‑side mode with
deferRender+scrollercan handle ~100 k rows if the data fits in memory, but the initial download and sort still block the main thread. - Expensive joins or full‑text search that you already run in SQL/Elasticsearch. Re‑using that logic avoids duplicating it in JavaScript.
- Real‑time or multi‑tenant data where the result set changes per request; sending the whole dataset would be a security leak.
If your data stays under ~10 k rows and the queries are trivial, client‑side remains simpler.
The request/response contract you must honor
With serverSide: true DataTables posts (or GETs) a predictable payload. The most important fields:
| Parameter | Meaning |
|---|---|
draw | Sequential integer echoed back unchanged; mismatched values make DataTables discard the response. |
start | Zero‑based offset for pagination. |
length | Page size (rows per page). |
order[i][column] | Visible column index to sort (after any ColReorder). |
order[i][dir] | asc or desc. |
search[value] | Global search string; must be OR‑ed across all searchable columns. |
columns[i][data] / name | Field name you’ll use in SELECT / WHERE. |
The JSON you return must contain exactly:
{
"draw": 1,
"recordsTotal": 100000,
"recordsFiltered": 1234,
"data": [
{ "id": 1, "name": "Acme", "email": "[contact removed]" },
{ "id": 2, "name": "Beta", "email": "[contact removed]" }
]
}
recordsTotal= rows before any filtering.recordsFiltered= rows after applying the global/column searches.data= array of objects (or arrays) matching the column definitions.
All three numeric fields must be integers—strings or null break pagination and the info text.
Common integration pitfalls
- Draw mismatch – If the server echoes a different integer (or omits it), DataTables stays in “processing” forever. Always return the exact
drawyou received. - Column index drift –
order[i].columnfollows the *visible* order. With the ColReorder extension, map to the original index viacolumns[].dataorcolumns[].name. - Global search logic – The backend must OR the search term across every column marked
searchable: true. Filtering only the first column is a frequent bug. - Large column counts – > 50 columns inflate the request because each column sends its own search/order metadata. Use
columns[].nameto keep the payload small. - Lost client‑side features – Row grouping, footer totals, and some plug‑ins that expect the full dataset in the browser stop working.
Worked example: minimal HTML + Express endpoint
Front‑end (DataTables 1.13+)
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.8/css/jquery.dataTables.min.css">
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/1.13.8/js/jquery.dataTables.min.js"></script>
</head>
<body>
<table id="users" class="display" style="width:100%">
<thead>
<tr><th>ID</th><th>Name</th><th>Email</th></tr>
</thead>
</table>
<script>
$('#users').DataTable({
serverSide: true,
ajax: {
url: '/api/users',
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: function (d) { return JSON.stringify(d); }
},
columns: [
{ data: 'id', name: 'id' },
{ data: 'name', name: 'name' },
{ data: 'email', name: 'email' }
],
order: [[0, 'asc']]
});
</script>
</body>
</html>
Back‑end (Node/Express, pseudo‑SQL)
app.post('/api/users', express.json(), async (req, res) => {
const { draw, start, length, order, search, columns } = req.body;
const sortCol = columns[order[0].column].name; // safe whitelist
const sortDir = order[0].dir === 'desc' ? 'DESC' : 'ASC';
const where = search.value
? `WHERE name ILIKE '%' || $1 || '%' OR email ILIKE '%' || $1 || '%'`
: '';
const params = search.value ? [search.value] : [];
// total rows
const total = await db.one('SELECT count(*) FROM users', []);
// filtered rows
const filtered = await db.one(`SELECT count(*) FROM users ${where}`, params);
// page data
const rows = await db.any(
`SELECT id, name, email FROM users ${where} ORDER BY ${sortCol} ${sortDir} LIMIT $${params.length+1} OFFSET $${params.length+2}`,
[...params, length, start]
);
res.json({ draw, recordsTotal: +total.count, recordsFiltered: +filtered.count, data: rows });
});
Key points in the snippet:
- Whitelist
sortColviacolumns[].nameto avoid SQL injection. - Global search builds a single
WHEREwithORacross searchable columns. - Both
recordsTotalandrecordsFilteredare cast to numbers. - The response echoes
drawunchanged.
Trade‑off: you gain scale, you lose some interactivity
Server‑side processing eliminates browser memory pressure, but every sort, page change, or keystroke triggers a network round‑trip. On a 50 ms latency link the UI feels snappy; on a 300 ms mobile connection the delay becomes noticeable. You also lose client‑side plug‑ins like RowGroup or automatic footer sums—those must be computed in the query or rendered separately.
Actionable next steps
- Spin up a tiny test page (the HTML above) pointed at a mock endpoint (e.g.,
mockapi.ioor a local Express route). - Open DevTools → Network; verify the first request contains
draw:1,start:0,length:10, and that the response mirrorsdrawand includes integerrecordsTotal/recordsFiltered. - Click a column header, type in the search box, go to page 2—confirm each new request increments
drawand updatesorder/search.value/start. - Return a 500 or malformed JSON once; ensure
$.fn.dataTable.ext.errMode(default "alert") surfaces the error so you can replace it with a custom handler. - Benchmark: load 10 k rows client‑side with
deferRendervs. server‑side with a 50 ms artificial delay. Compare initial paint and heap size in the Performance panel.
If the numbers line up and the UX stays acceptable, you’ve bought yourself headroom for millions of rows without rewriting the front end.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.