# Exposer un annuaire de 100 000 fiches en headless sans épuiser l’API

> Un front qui parcourt l'intégralité d'un annuaire via page et per_page finit par déclencher des timeouts. La pagination par curseur règle le problème à la racine.

- Auteur : WordPress Développement
- Publié le : 2020-05-12
- Mis à jour le : 2020-05-12
- Catégorie : Headless &amp; API
- URL : https://www.wpmoderne.fr/headless/annuaire-100000-fiches-headless-pagination-curseur/

## L’essentiel

- Le paramètre offset devient plus lent à mesure qu'on avance dans les pages
- Un curseur basé sur l'ID élimine ce ralentissement
- Le tri doit rester strictement stable pour que le curseur fonctionne

`?page=850&per_page=100` : ce paramètre, anodin en apparence, est celui qui a fini par faire timeout l'API REST d'un annuaire professionnel comptant 100 000 fiches, exposé en headless à un front qui devait en parcourir l'intégralité pour construire un export complet côté client.

Le problème ne se voit pas sur les premières pages. Il apparaît progressivement, à mesure que le numéro de page augmente, jusqu'à devenir un vrai goulot d'étranglement sur les dernières pages du jeu de données. La cause est connue en base de données depuis longtemps : la pagination par décalage (`offset`) coûte de plus en plus cher à mesure que le décalage grandit.

## Le problème : une pagination qui ralentit avec le volume

La pagination native de l'API REST WordPress, via `page` et `per_page`, se traduit en interne par une clause SQL `LIMIT offset, per_page`. Pour afficher la page 850 avec cent éléments par page, MySQL doit d'abord parcourir les 84 900 lignes précédentes avant de pouvoir en retourner cent. Ce coût grandit linéairement avec l'avancement dans la pagination, ce qui explique pourquoi les premières requêtes d'un parcours complet répondent en quelques dizaines de millisecondes, et les dernières en plusieurs secondes.

Sur un annuaire de 100 000 fiches parcouru par un job de génération statique ou un export périodique, ce ralentissement progressif finissait par dépasser le délai maximal d'exécution PHP, interrompant le parcours avant son terme.

## La solution : paginer par curseur plutôt que par décalage

La pagination par curseur remplace le numéro de page par une référence au dernier élément vu — ici, son identifiant. Chaque requête suivante demande « les cent éléments dont l'identifiant est supérieur au dernier reçu », ce qui se traduit en SQL par une clause `WHERE ID > %d` combinée à un index sur la clé primaire — un index que MySQL utilise nativement, sans jamais avoir à parcourir les lignes précédentes.

> L'essentiel à retenir : Le paramètre offset devient plus lent à mesure qu'on avance dans les pages ; Un curseur basé sur l'ID élimine ce ralentissement ; Le tri doit rester strictement stable pour que le curseur fonctionne

## Le snippet : un endpoint personnalisé à curseur

Le point d'entrée natif de l'API REST ne propose pas ce mode de pagination : il faut l'ajouter via un endpoint dédié, enregistré avec `register_rest_route` :

```
add_action('rest_api_init', function () {
    register_rest_route('annuaire/v1', '/fiches', [
        'methods'  => 'GET',
        'callback' => 'annuaire_fiches_par_curseur',
        'args'     => [
            'after' => [
                'type'              => 'integer',
                'default'           => 0,
                'sanitize_callback' => 'absint',
            ],
            'per_page' => [
                'type'              => 'integer',
                'default'           => 100,
                'sanitize_callback' => 'absint',
            ],
        ],
    ]);
});

function annuaire_fiches_par_curseur(WP_REST_Request $request) {
    global $wpdb;

    $after    = $request->get_param('after');
    $per_page = min(200, $request->get_param('per_page'));

    $ids = $wpdb->get_col($wpdb->prepare(
        "SELECT ID FROM {$wpdb->posts}
         WHERE post_type = 'fiche_annuaire'
           AND post_status = 'publish'
           AND ID > %d
         ORDER BY ID ASC
         LIMIT %d",
        $after,
        $per_page
    ));

    $fiches = array_map(function ($id) {
        $post = get_post($id);
        return [
            'id'    => $post->ID,
            'titre' => $post->post_title,
            'lien'  => get_permalink($post),
        ];
    }, $ids);

    $next_cursor = !empty($ids) ? end($ids) : null;

    return rest_ensure_response([
        'fiches'      => $fiches,
        'next_cursor' => $next_cursor,
        'has_more'    => count($ids) === $per_page,
    ]);
}
```

Le front n'a plus qu'à rejouer l'appel avec `after` égal au `next_cursor` reçu, jusqu'à ce que `has_more` devienne `false`. Chaque requête reste à coût constant, quel que soit l'avancement dans le parcours.

## Variantes utiles

- **Tri par date plutôt que par ID** : dans ce cas, le curseur doit combiner date et identifiant (`WHERE (post_date, ID) > (%s, %d)`) pour rester stable même en cas de dates identiques à la seconde près.
- **Curseur encodé** : plutôt que d'exposer directement l'identifiant, certaines API encodent le curseur en base64 pour éviter que le front ne le manipule ou ne le devine, sans que cela change la logique de pagination sous-jacente.
- **Comptage total optionnel** : contrairement à la pagination classique, un curseur ne fournit pas nativement le nombre total de pages restantes ; si ce chiffre est nécessaire à l'affichage, il doit être calculé séparément, avec un coût à part.

### Le point de vigilance : le tri doit être strictement stable

Un curseur ne fonctionne que si l'ordre de tri ne change jamais entre deux appels : trier sur une colonne pouvant contenir des doublons, sans clé de départage, expose à sauter ou répéter des éléments d'une page à l'autre. L'identifiant, unique par nature, reste le choix le plus sûr en l'absence d'un besoin de tri spécifique.

> Une pagination qui ralentit avec le volume n'est pas un problème qu'on découvre en développement : elle attend patiemment que le jeu de données grossisse pour se manifester en production.

## En résumé

Ce correctif ne touche pas à l'indexation de l'annuaire pour la recherche, qui reste un sujet distinct : il règle uniquement la capacité du front à parcourir l'intégralité du contenu sans faire exploser le temps de réponse de l'API à mesure que le volume augmente. Un curseur basé sur l'identifiant, combiné à un index déjà présent nativement sur la clé primaire, suffit à transformer une pagination linéairement croissante en une pagination à coût constant.
