Практики работы с запросами на сервере значительно отличаются от того, к чему привык фронтенд-разработчик, ежедневно разрабатывающий SPA-приложения с клиентским рендерингом. Если не учесть эту разницу при разработке приложения с серверным рендерингом, то можно собрать довольно много граблей. Хочу поделиться опытом и рассказать про три практики, которые использую повседневно, а также о проблемах, предшествующих их появлению. Я буду ссылаться на web-performance и рассчитываю что вы уже знакомы с такими метриками как TTFB, LCP и FCP.

Оглавление

  • Таймауты

  • Ретраи

  • Загрузка данных

  • Итог

Таймауты

Прерывать запросы на стороне клиента — не самое частое занятие в жизни фронтенд-разработчика. А прерывание запросов на стороне сервера — уже важнейшее требование, которое нельзя упускать из виду. Сеть не так надёжна, как швейцарские часы, а сторонние API склонны к разного рода проблемам. Реализуя серверный рендеринг, мы должны изучить, как долго отвечает каждый отдельный хендлер (далее буду его называть «ручкой»). Одна из причин, почему этим стоит заниматься, кроется в метрике TTFB, хорошо знакомой многим разработчикам. От неё уже зависят такие метрики как FCP и LCP, которые суммарно могут дать или отнять 35 баллов из 100 возможных в Lighthouse.

Для наглядности разберём один пример. Представим что у нас есть веб‑сайт, задача которого — выводить информацию о доме. Мы хотим хорошо индексироваться в поиске, а так как домов довольно много, статический пререндеринг нам не подойдёт, поэтому мы развернули Node.js (далее «нода»). У нас есть несколько источников данных, один из них корневой домен, который отдаёт данные по дому. Есть ещё три сервиса, которые отдают вспомогательные, или, можно сказать, данные вторичной важности, напрямую не являющиеся критичными для отображения страницы с точки зрения пользователей и микроразметки для SEO.

Пользователь запрашивает страницу, запрос попадает на ноду, она в свою очередь идёт асинхронно во все сервисы и собирает нужные данные. Самый долгий запрос в этой схеме занимает 80 миллисекунд. Допустим, длительность формирования HTML-документа занимает 100 миллисекунд, а накладные расходы на передачу по сети составляют 75 миллисекунд. В итоге наш пользователь получает страницу за 255 миллисекунд. Что довольно неплохо, если ориентироваться на метрики TTFB, рекомендованные Google. На текущий момент хорошим результатом является показатель TTFB до 800 миллисекунд, хотя, как по мне, долговато. Я предпочитаю целится в 200 на 99 перцентиле.

Что может пойти не так? Например, один из сервисов начнёт испытывать сложности и латенси значительно вырастет.

Это приведёт к тому, что длительность ответа страницы замедлится более чем на 20 секунд, и пользователь уйдёт с сайта, так и не дождавшись загрузки, получив при этом негативный опыт. На текущий день в Node.js значение по умолчанию для timeout равно 0. Иными словами, он не предусмотрен. В базовой конфигурации fetch и axios тоже не прерывают запросы. Чтобы решить эту проблему, нужно предусмотреть таймауты для каждой ручки, исходя из суммарного бюджета 800 миллисекунд на TTFB. В этом случае 500 миллисекунд на таймаут выглядит довольно логично, ведь нам ещё потребуется примерно 100 миллисекунд на рендеринг HTML на сервере, и примерно 100 миллисекунд на передачу данных по сети, даже немного останется запас. Обратите внимание: если ручки, в которые вы ходите, работают гораздо медленней, то это может привести к проблемам с UX и SEO, что делать с этим, я рассмотрю чуть ниже. 

Пример запроса с таймаутом на экземпляре:

import axios from 'axios';

const axiosInstance = axios.create({
 baseURL: 'https://jsonplaceholder.typicode.com',
 timeout: 500
});

axiosInstance
  .get('/posts')
  .then(postsData => console.log(postsData));

Или для каждого запроса отдельно:

import axios from 'axios';

axios
  .get('https://jsonplaceholder.typicode.com/posts', { timeout: 500 })
  .then(postsData => console.log(postsData));

Если вы не используете axios, то можете воспользоваться fetch с AbortSignal.timeout

Также стоит установить глобальный таймаут на тот случай, если кто-то забыл указать явный таймаут в запросе; что-то вроде предохранителя. В примере я установил его на три секунды, но в реальности нужно исходить из того, насколько быстро работают ваши ручки и какая допустимая в худшем случае длительность ответа.

const http = require('http');

const server = http.createServer((req, res) => {
 setTimeout(() => {
   res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
   res.write('слишком долго отвечал и не успел ответить');
   res.end();
 }, 5000);
});

server.timeout = 3000;

server.listen(3000);

Если переборщить со строгостью таймаутов, то можно уйти в противоположную крайность: зачастую будем обрубать запросы и наша страница перестанет насыщаться полезной разметкой для продвижения в поиске. А, как вы помните, самоцель серверного рендеринга — именно SEO.

Можно установить повышенные таймауты для ботов. Для этого нужно определить, что пришёл именно бот, благо сделать это не трудно. Можно так, например:

const userAgent = req.headers['user-agent'];
const isBot = Boolean(userAgent?.match(/googlebot|yandex/i));

Для ботов я предпочитаю указывать таймаут таким образом, чтобы уже не выползать из зоны «нужны улучшения». Лимит у неё до 1800 миллисекунд, значит ставим с запасом таймаут в 1500 миллисекунд. Учитывайте, что длительный TTFB негативно влияет на индексацию. Но практика показывает что микроразметка значит намного больше, чем TTFB, в нашем же случае мы ловко балансируем между тем и тем.

Ретраи

Представим, что мы оборвали аномально долгий запрос, что с этим делать дальше? Попробовать перезапросить данные выглядит логичной идеей. Важно помнить о том, что у нас есть бюджет в 800 миллисекунд и нехорошо из него выбиваться. А неаккуратная работа с ретраями может привести к тому, что и без того задыхающийся сервис просто упадёт.

 Есть несколько вариантов развития событий:

  1. Сделать ретрай на сервере.

  2. Сделать ретрай на клиенте.

  3. Комбинировать первый и второй способ.

  4. Не делать ретрай вообще.

Ретрай на сервере нужен в том случае, когда данные настолько важны, что без них не имеет смысла отдавать страницу. Также бывают случаи, когда ручка работает довольно быстро, но что-то пошло не так и запрос повис, в этом случае ретрай может помочь. Я бы не стал делать более одного ретрая на сервере: как я упомянул выше, можно навредить.

На клиенте проверяем, есть ли нужные данные. Если их нет, то повторно запрашиваем. Если данные не нужны на первом экране, то лучше их отложить до востребования, например когда пользователь достиг зоны отображений этих данных, используя, скажем intersection observer API. Это сделает страницу легче на «подъём», а также позволит снизить количество запросов к многострадальному сервису.

Загрузка данных

Server-side rendering не подразумевает обязательный сбор всех данных на стороне сервера. Никто и ничто не запрещает перемещать запрос части данных на клиент. Что обязательно нужно собрать на сервере, так это данные для SEO, которые будут использованы для микроразметки, мета-тегов и обычной разметки, которая также нужна для индексации, плюс всю целевую информацию для конкретной страницы. А вот всё остальное можно вынести на клиент, особенно ручки с большой длительностью ответа.

Если оперировать примером из главы про таймауты, то мы можем избавиться от трёх лишних запросов. Так как данные мы запрашивали не просто так, убираем необходимость как-либо обрабатывать их и ждать ответа от другого сервиса, не позволяя ноде отдать контент пользователю. Самая долгая операция на ноде при бизнес-процессе, взятом из примера, это рендеринг страницы. Если мы на сервере не будем рисовать необязательные блоки, то, опять же, сократим длительность рендеринга и уменьшим вес страницы. Что положительно повлияет и на проверку размера DOM-дерева.

Не забывайте, что подгружать данные на клиенте нужно с помощью ленивой загрузки, для этого всё так же рекомендую intersection observer API.

Итак, что вы получите, если озаботитесь разделением логики запросов на сервер и клиент:

  • Снизите нагрузку на сервисы, из которых брали данные.

  • Получите оптимизацию Node.js-сервера.

  • Улучшите web-performance приложения.

Подведём итог

Для лучшего пользовательского опыта TTFB вашей страницы не должен превышать 800 миллисекунд. Поэтому таймауты на ручки составляют 500 миллисекунд. Для ботов таймаут можно сделать 1500 миллисекунд. Эти тайминги показали себя отлично для бэкенда, который отвечает около 200 миллисекунд на 99 перцентиле.

Все запросы, не влияющие на SEO и UX, можно вынести на клиент и загружать по мере достижения контента пользователем.

Аккуратно работаем с ретраями, не стоит делать более одного без особой нужды.

Все перечисленные практики помогают сделать приложение устойчивей ко всякого рода сетевым проблемам, ускорить отдачу страницы, а также снизить нагрузку как на свою ноду, так и на все сервисы, в которые ходим за данными.

Спасибо за внимание.

Комментарии (1)


  1. Spaceoddity
    30.11.2023 13:53

    Это приведёт к тому, что длительность ответа страницы замедлится более чем на 20 секунд, и пользователь уйдёт с сайта, так и не дождавшись загрузки, получив при этом негативный опыт.

    Хорошо, выставили вы таймауты - для пользователя-то что изменится? Он все равно уйдёт с сайта, так как приложение не получит ответ от сервера. Ну хорошо, вы его можете проинформировать о проблемах на сервере через секунду, но контента ведь он все равно не увидит...

    Я только вчера как раз задумался о сути этих таймаутов. По сути таймаут сетевого интерфейса - это "показатель максимальной продолжительности показа лоадера"))

    Оставим даже в стороне быстродействие сервера (500мс на всё про всё?). Такие "временные зазоры" (продолжительностью до секунды) на самом деле очень неудобны для пользовательских интерфейсов - и не мгновенно манипуляции происходят и "лаг" слишком короткий чтобы что-то вменяемое показать (заставить спиннер на экране сделать хотя бы пару оборотов). В итоге интерфейс получается слишком "неотзывчивым".

    На пальцах: Яндекс.маркет и Озон (это, по ходу, родовая болячка подобных проектов) - кликаю по чекбоксам в панели фильтрации. После каждого onchange начинается ререндер не только поисковой выдачи, но и панели фильтрации ("оптимизировали" - помогаем пользователю, убрав неактуальные варианты фильтрации). Я по привычке начинаю быстро протыкивать чекбоксы. Уже второй клик приходится не на чекбокс, а или на модалку (которая вылезает на время ререндера посиковой выдачи), или на совершенно левый чекбокс (который встанет на место целевого из-за ререндера панели фильтрации)... Это конечно всё надо рассматривать на конкретном примере, но это я всё только ради тезиса к которому хотел подвести:

    Продолжительность обратной реакции интерфейса сопоставимая со временем человеческой реакции - крайне неюзабельна. Либо делайте мгновенную отрисовку, либо дайте мне "зазор" на осознание изменений в интерфейсе.