Server‑Side Pagination, Sorting and Filtering with Quasar QTable (Vue 3)
An architecture note that outlines the requirements, minimal design, trust boundaries, operational checks, failure modes and conditions that would change the design when using QTable in server‑driven mode.
12 Apr 2026, 14:51 UTC

Requirements
The goal is to offload paging, sorting and filtering work to the backend while keeping the QTable UI thin and responsive. The table must:
- Receive pagination details (page, rowsPerPage, sortBy, descending, filter) from QTable via the
@requestevent. - Return a payload containing the slice of rows for the current page and the total number of rows that match the current filter (
rowsNumber). - Update the UI instantly when the response arrives, without client‑side slicing.
These requirements assume a dataset large enough that client‑side processing would cause noticeable latency (typically > 10 k rows or complex filter logic).
Smallest Suitable Design
A single REST‑like endpoint that mirrors the shape of the pagination object emitted by QTable is sufficient.
Client‑Side QTable (Vue 3)
<template>
<q-table
title="Products"
:rows="rows"
:pagination="pagination"
@request="onRequest"
row-key="id"
:loading="loading"
>
<th v-slot="top"></th>
<template v-slot:body>
<td v-for="col in columns" :key="col.name">{{ row[col.name] }}</td>
</template>
</q-table>
</template>
<script setup>
import { ref } from 'vue'
const rows = ref([])
const pagination = ref({
page: 1,
rowsPerPage: 10,
sortBy: 'name',
descending: false,
rowsNumber: 0
})
const loading = ref(false)
async function onRequest(payload) {
loading.value = true
try {
const resp = await fetch('/api/products', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload.pagination)
})
const data = await resp.json()
rows.value = data.rows
pagination.value.rowsNumber = data.rowsNumber
} finally {
loading.value = false
}
}
</script>
Server‑Side Endpoint (pseudo‑code)
GET /api/products
Query parameters (sent as JSON body):
page: integer (1‑based)
rowsPerPage: integer
sortBy: string
descending: boolean
filter: string (optional, raw text from QTable filter)
Logic:
1. Validate sortBy against an allow‑list of column names.
2. Clamp rowsPerPage to a configurable maximum (e.g., 100).
3. Build WHERE clause from filter (if any) using parameterised queries.
4. Run COUNT query to get total matching rows → rowsNumber.
5. Run SELECT query with LIMIT/OFFSET derived from page and rowsPerPage,
ORDER BY validated sortBy and descending flag.
6. Return JSON: { rows: [...], rowsNumber: }
This design keeps the UI layer responsible only for rendering rows and forwarding the pagination object.
Trust and Data Boundaries
The pagination object originates from the client and must be treated as untrusted input.
- Sort column: Validate
sortByagainst a server‑side allow‑list (e.g., ['id','name','price','createdAt']). Reject or default to a safe column if the value is not permitted. - Page size: Apply an upper bound (e.g.,
Math.min(requested, MAX_PAGE_SIZE)) to prevent excessive resource consumption. - Filter text: Although the filter is free‑form, it should be used only in parameterised queries or full‑text search APIs that treat the string as data, not SQL.
- Pagination numbers: Ensure
pageandrowsPerPageare positive integers; otherwise reset to defaults.
These checks sit at the API gateway or controller layer, forming a clear trust boundary between the UI and the data store.
Operational Checks
To maintain confidence in the design, monitor the following metrics:
- Query latency: Log the duration of the COUNT and SELECT queries separately. Sudden increases may indicate missing indexes or data skew.
- rowsNumber accuracy: Periodically compare the returned
rowsNumberwith an independent count (e.g., a nightly batch) to detect drift caused by stale caches or incorrect filter handling. - Request cancellation rate: Track HTTP 499 (client closed request) or aborted fetch events. A high rate often signals that the debounce interval is too short, causing rapid successive requests that cancel each other.
- Error rates: Count validation failures (invalid sort column, out‑of‑range page) to spot client‑side bugs or malicious probing.
Alert thresholds should be based on baseline measurements from a staging environment with realistic load.
Failure Modes
Even with validation, certain scenarios can degrade the user experience:
- Stale page after filter change: If the user filters while on page 5 and the result set shrinks to fewer than five pages, the UI may display an empty table. The server should always return the correct slice for the requested page, but the client must reset
pagination.pageto 1 whenever the filter string changes. This can be done in the@requesthandler before sending the payload. - Out‑of‑range page after deletions: When rows are removed concurrently, the requested page may exceed the new total. The server should detect this condition and either return the last valid page (adjusting
pagein the response) or return an empty rows array with the correctrowsNumber. The UI will then show the footer reflecting the correct total and allow the user to navigate back. - Duplicate rapid sort toggles: Clicking a column header twice in quick succession can generate two requests with opposite
descendingflags. If the first request is still in flight, the second may overwrite the UI with stale data. Implementing a request ID or usingAbortControllerto cancel the previous request mitigates this. - Filter debounce race: Without debouncing, each keystroke triggers a request. Network reordering can cause an older request to arrive after a newer one, showing outdated results. A client‑side debounce of 300‑500 ms before emitting
@requesteliminates most races.
Conditions That Would Change the Design
The server‑driven approach is optimal when:
- The dataset exceeds a few thousand rows or the filter involves complex computations (e.g., full‑text search, multi‑table joins).
- Latency of a single round‑trip is acceptable (< 200 ms) and the backend can sustain the query load.
If the dataset is small (e.g., < 2 000 rows) and the filter is simple, fetching the entire dataset once and letting QTable handle paging, sorting and filtering client‑side removes the network round‑trip entirely, simplifies error handling, and improves perceived responsiveness. In that case, the design would shift to:
- Initial load:
GET /api/productsreturns all rows. - QTable operates in client‑only mode (no
@requesthandler, norowsNumberrequired). - Optional refresh endpoint for periodic updates.
The decision should be revisited as data volume grows or as query performance characteristics change.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.