Back to all articles
DevOps & Infrastructure•2026-09-30•13 min read

Next.js saytni 12 soniyadan 0.3 soniyaga tushirish: kim-bu.uz tajribasi

Sahifa ochilishini kutib qolish muammosi qanday o'lchandi, sababi qayerdan topildi va ISR, Cloudflare cache hamda CI'dagi Docker build bilan qanday hal qilindi.

Next.jsCloudflareDockerGitHub ActionsPerformance

kim-bu.uz — telefon raqami qaysi tashkilotga tegishli ekanini ko'rsatadigan sayt. Stack oddiy: Next.js 15 (App Router) + PostgreSQL + Prisma, hammasi bitta VPS'da Docker Compose bilan ishlaydi, oldida Cloudflare turadi.

Bir kuni o'zim sezib qoldim: ba'zi sahifalarni ochganda uzoq kutib qolaman. Doim emas, lekin tez-tez.

Bu maqolada shu muammo qanday o'lchangani, qaysi taxminlar noto'g'ri chiqqani va oxirida nima qilinganini yozaman.

Eng muhim dars: taxmin qilma — o'lcha. Men bu yo'lda kamida ikki marta noto'g'ri gipoteza qildim.


1. Birinchi qadam: o'lchash

Brauzerda "sekin" deyish hech narsa bermaydi. Menga aniq raqamlar kerak edi: qaysi sahifa, qancha vaqt, cache'dan keldimi yoki yo'q.

Buning uchun curlning -w opsiyasi juda qulay:

Code Snippet
curl -sS -o /dev/null -D headers.txt \
  -w "ttfb=%{time_starttransfer} total=%{time_total}\n" \
  https://kim-bu.uz/ru
 
grep -iE 'x-nextjs-cache|cache-control|cf-cache-status' headers.txt

Bu yerda eng muhim uchta header:

HeaderNimani ko'rsatadi
x-nextjs-cacheNext.js sahifani cache'dan berdimi (HIT), eskirgan cache'dan (STALE) yoki qaytadan render qildimi (MISS)
cache-controlSahifa qancha vaqt cache'da yashashi mumkin
cf-cache-statusCloudflare javobni o'zidan berdimi (HIT) yoki origin'ga bordimi (DYNAMIC, MISS)

Birinchi natijalar:

HolatTTFB
Next.js cache HIT0.4–1 s
Cache MISS / STALE (render)6–12 s
Qidiruv sahifasi (doim render)5–11 s
/api/search/suggest (o'sha qidiruv, lekin render'siz)0.4 s
/api/health (DB so'rovi bilan)0.4 s

Va har bir HTML javobda:

Code Snippet
cf-cache-status: DYNAMIC

Ya'ni Cloudflare bitta ham HTML sahifani cache qilmayotgan edi — har bir so'rov serverga borardi.


2. Sabab qayerda? Imkoniyatlarni bittalab yopish

Jadvalga qarasak, qiziq narsa bor: qidiruv API'si 0.4 soniyada javob beradi, lekin xuddi shu qidiruvni ko'rsatadigan sahifa 10 soniya oladi.

Demak:

  • DB sekin emas — API o'sha so'rovni tez bajaryapti.
  • Muammo render jarayonida.

Keyingi savol: kod sekinmi?

Buni tekshirish uchun lokal kompyuterda production build qildim va o'sha sahifalarni o'lchadim:

Code Snippet
npm run build
npx next start -p 3100
SahifaLokal (prod build)Production server
Raqam sahifasi20–40 ms6–12 s
Qidiruv16–40 ms5–11 s

250 barobar farq. Bitta kod — ikki xil natija. Demak kodning o'zi emas, muhit aybdor.

Build log'da yana bir qiziq narsa ko'rdim:

Code Snippet
● /[locale]/raqam/[number]      5.38 kB   261 kB   5m   1y

Sahifada export const revalidate = 3600 yozilgan, lekin Next.js uni 5 daqiqa deb hisoblayapti. Bu birinchi aniq muammo edi.


3. Muammo №1: bitta unstable_cache butun saytning ISR'ini qisqartirgan

Next.js App Router'da sahifaning haqiqiy revalidate vaqti — u render paytida ishlatgan barcha cache'lar ichidagi eng kichik qiymat.

Layout'da sayt sozlamalari (reklama, analitika ID'lari) shunday olinardi:

Code Snippet
export const getSiteSettings = unstable_cache(
  fetchSiteSettings,
  ["site-settings"],
  { revalidate: 300, tags: ["site-settings"] },
);

Layout esa har bir sahifada render bo'ladi. Natijada 300 soniya hamma sahifaga "yuqtirilgan": raqam sahifasi ham, tashkilot sahifasi ham, bosh sahifa ham har 5 daqiqada eskirib, qaytadan render bo'lishi kerak edi.

Qizig'i shundaki, bu qisqa TTL'ga hech qanday ehtiyoj yo'q edi — admin panelda sozlama saqlanganda cache baribir darhol tozalanadi:

Code Snippet
revalidateTag("site-settings");

Yechim

Code Snippet
export const getSiteSettings = unstable_cache(
  fetchSiteSettings,
  ["site-settings"],
  // Every save revalidates the "site-settings" tag, so a long TTL is safe. A short one would
  // cap every page's ISR lifetime (they all render the layout) and force re-renders.
  { revalidate: 3600, tags: ["site-settings"] },
);

Natijada sahifalar 12 barobar kamroq qayta render bo'ladi, cache-control ham o'zgardi:

Code Snippet
Oldin:  cache-control: s-maxage=300,  stale-while-revalidate=...
Keyin:  cache-control: s-maxage=3600, stale-while-revalidate=...

Dars: layout'dagi har bir unstable_cache yoki fetch(..., { next: { revalidate } }) butun sayt uchun yuqori chegara bo'ladi. Build log'dagi "Revalidate" ustuniga qarab turing.


4. Muammo №2: Cloudflare HTML'ni cache qilmayapti

Sahifa Next.js cache'idan kelganda ham 0.4–1 soniya ketardi. Sababi oddiy: har bir so'rov Cloudflare'dan o'tib, serverga borib qaytardi.

Cloudflare default holatda HTML'ni cache qilmaydi — faqat JS, CSS, rasm kabi statik fayllarni. HTML uchun alohida Cache Rule yozish kerak.

Lekin shunchaki rule yoqish yetarli emas edi. Header'larni ko'rib chiqsam, ikkita to'siq bor edi.

Code Snippet
set-cookie: NEXT_LOCALE=uz; Path=/; SameSite=lax

next-intl har bir javobga til cookie'sini qo'yardi. Cloudflare esa Set-Cookie bor javobni cache qilmaydi — va bu to'g'ri, aks holda bir foydalanuvchining cookie'si boshqasiga ketib qolishi mumkin.

To'siq 2: Accept-Language bo'yicha redirect

Code Snippet
curl -H "Accept-Language: ru-RU" https://kim-bu.uz/
# 307 → https://kim-bu.uz/ru

Sayt brauzer tiliga qarab foydalanuvchini boshqa URL'ga yo'naltirardi. Agar Cloudflare / sahifasining o'zbekcha versiyasini cache qilsa, rus tilidagi foydalanuvchi redirect'ni ko'rmay, to'g'ridan-to'g'ri o'zbekcha sahifani oladi.

Ya'ni bitta URL har xil foydalanuvchiga har xil javob beradi — bu CDN cache uchun eng yomon holat.

Yechim: til faqat URL'dan olinadi

Code Snippet
export const routing = defineRouting({
  locales: ["uz", "uz-Cyrl", "ru", "en"],
  defaultLocale: "uz",
  localePrefix: { mode: "as-needed" },
  // The locale comes from the URL only: no Accept-Language redirects and no NEXT_LOCALE
  // cookie. Each URL then always returns the same page, so Cloudflare can cache the HTML.
  localeDetection: false,
  localeCookie: false,
  // ...
});

Endi / doim o'zbekcha, /ru doim ruscha. Foydalanuvchi tilni switcher orqali almashtiradi.

Bu SEO uchun ham yaxshi: Google avtomatik til redirect'larini tavsiya qilmaydi, chunki crawler sahifaning boshqa til versiyalarini ko'ra olmay qolishi mumkin.

Cloudflare Cache Rule

Caching → Cache Rules → Create rule, expression:

Code Snippet
(http.host eq "kim-bu.uz"
 and not starts_with(http.request.uri.path, "/api")
 and not starts_with(http.request.uri.path, "/admin")
 and not starts_with(http.request.uri.path, "/monitoring")
 and not http.request.uri.path in {"/qidiruv" "/uz-Cyrl/qidiruv" "/ru/poisk" "/en/search"}
 and not any(http.request.headers["rsc"][*] eq "1"))

Sozlamalar:

  • Cache eligibility: Eligible for cache
  • Edge TTL: Use cache-control header if present, bypass cache if not
  • Browser TTL: Respect origin
  • Serve stale content while revalidating: yoqilgan

Ikki narsaga alohida e'tibor bering.

1. rsc header sharti. Next.js client-side navigatsiyada xuddi shu URL'ga RSC: 1 header bilan so'rov yuborib, HTML emas, RSC payload oladi. Next.js buni Vary: rsc bilan belgilaydi, lekin Cloudflare Varyni hisobga olmaydi. Bu shart bo'lmasa, Cloudflare RSC payload'ni cache qilib, keyin oddiy foydalanuvchiga HTML o'rniga berib yuborishi mumkin.

2. "Respect origin". Edge TTL'ni Next.js o'zi beradi: ISR sahifalar uchun s-maxage=3600, dinamik sahifalar (404, qidiruv) uchun no-store. Shunda Cloudflare qaysi sahifani qancha saqlashni Next.js'dan "so'raydi" va dinamik sahifalarni tasodifan cache qilib qo'ymaydi.

Natija:

Code Snippet
/ #1  cf-cache-status: MISS  ttfb=1.27s
/ #2  cf-cache-status: HIT   ttfb=0.26s

5. Muammo №3: sekinlik vaqti-vaqti bilan paydo bo'ladi

Cache ishladi, lekin render o'zi hali ham ba'zan 8–20 soniya olardi. Birinchi gipotezam: server CPU yetishmayapti.

Serverda tekshirdim:

Code Snippet
load average: 1.40, 2.09, 2.59
%Cpu(s):  3.9 us,  5.9 sy, 88.2 id
kimbu-app   0.01%   263.2MiB

Server deyarli bo'sh. Gipoteza rad etildi.

Ikkinchi qadam — so'rovni to'rt xil yo'l bilan, server ichidan o'lchash. Bu sekinlik qaysi bo'g'inda paydo bo'layotganini ajratib beradi:

Code Snippet
# 1. to'g'ridan app'ga
curl -w "%{time_starttransfer}\n" http://localhost:3000/qidiruv?q=test
# 2. app'ga, lekin haqiqiy Host header bilan
curl -H "Host: kim-bu.uz" http://localhost:3000/qidiruv?q=test
# 3. server'dagi nginx orqali
curl --resolve kim-bu.uz:443:127.0.0.1 https://kim-bu.uz/qidiruv?q=test
# 4. Cloudflare orqali
curl https://kim-bu.uz/qidiruv?q=test
Yo'lVaqt
App, localhost0.14–0.30 s
App, Host: kim-bu.uz0.14–1.47 s
nginx orqali0.10–0.62 s
Cloudflare orqali0.27–0.45 s

Hammasi tez. Bir soat oldin 10 soniya bo'lgan so'rov endi 0.3 soniya.

Demak sekinlik doimiy emas. Qachon paydo bo'lganini deploy tarixiga solishtirdim:

Code Snippet
17:35 → 17:42  deploy
17:58 → 18:05  deploy
18:20 → 18:29  deploy
19:12 → 19:19  deploy

Men o'lchagan sekin natijalarning deyarli hammasi deploy'lar paytida yoki darhol keyin bo'lgan edi.

Nega? Eski deploy jarayoni shunday edi:

Code Snippet
deploy:
	git pull
	docker compose up -d --build

Ya'ni Next.js build production serverning o'zida ishlardi. Bu 7–9 daqiqa davomida 4 yadroli serverni band qiladi — va shu serverda yana 20 ta boshqa konteyner ham bor.

Buning ustiga har deploy'dan keyin:

  1. yangi konteyner "sovuq" ishga tushadi — birinchi render'lar sekin;
  2. revalidate endpoint hamma sahifani eskirgan deb belgilaydi — birinchi tashriflar qaytadan render kutadi.

Uch narsa bir vaqtga to'g'ri kelsa, 10–20 soniyalik javoblar paydo bo'ladi.


6. Yechim: build'ni serverdan olib chiqish

Yangi deploy jarayoni:

Code Snippet
GitHub Actions                       Server
─────────────                        ──────
CI (lint, test, build) ✓
    ↓
Docker image build
    ↓
GHCR'ga push  ──────────────────→   docker pull
                                        ↓
                                    docker compose up -d --no-build
                                        ↓
                                    health check
                                        ↓
                                    revalidate
                                        ↓
                                    warm-up (84 ta sahifa)
    ↓
Cloudflare cache purge

Image CI'da build qilinadi

Code Snippet
- name: Build and push app image
  uses: docker/build-push-action@v6
  with:
    context: .
    target: runner
    push: true
    tags: ${{ env.APP_IMAGE }}:${{ env.SHA }},${{ env.APP_IMAGE }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max
    build-args: |
      NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL }}
      # ...
    secrets: |
      SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}

Bu yerda bitta nozik joy bor: NEXT_PUBLIC_* o'zgaruvchilar build vaqtida client bundle'ga yozib qo'yiladi. Oldin ular serverdagi .envdan olinardi, endi GitHub secret'lardan olinadi. Ularni ko'chirishni unutsangiz, sayt bo'sh analitika ID'lari bilan chiqib ketadi. Shuning uchun workflow'da oldindan tekshiruv qo'ydim:

Code Snippet
- name: Check build secrets
  run: |
    if [ -z "$NEXT_PUBLIC_SITE_URL" ]; then
      echo "::error::NEXT_PUBLIC_SITE_URL secret is not set"
      exit 1
    fi

Sentry token esa build-arg emas, build secret sifatida uzatiladi — shunda u image tarixida qolmaydi:

Code Snippet
RUN --mount=type=cache,target=/app/.next/cache \
    --mount=type=secret,id=SENTRY_AUTH_TOKEN,required=false \
    if [ -f /run/secrets/SENTRY_AUTH_TOKEN ]; then \
      export SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN)"; \
    fi && \
    npm run build

Server faqat pull qiladi

Code Snippet
deploy-image:
	for ref in $(APP_IMAGE_REF) $(BUILDER_IMAGE_REF); do \
	  for i in 1 2 3; do docker pull $$ref && break; [ $$i = 3 ] && exit 1; sleep 5; done; \
	done
	docker tag $(APP_IMAGE_REF) kimbu-app:latest
	docker tag $(BUILDER_IMAGE_REF) kimbu-builder:latest
	docker rmi $(APP_IMAGE_REF) $(BUILDER_IMAGE_REF)
	docker compose up -d --no-build
	docker image prune -f

Private GHCR'dan pull qilish uchun alohida token yaratish shart emas: job'ning GITHUB_TOKENi SSH orqali serverga uzatiladi, login qilinadi, pull'dan keyin docker logout. Token job tugashi bilan o'z-o'zidan eskiradi.

Warm-up

Deploy'dan keyin eng ko'p ochiladigan sahifalar foydalanuvchidan oldin render qilinadi:

Code Snippet
PATHS="/ /uz-Cyrl /ru /en"
PATHS="$PATHS $(curl -fsS -H 'Host: kim-bu.uz' -H 'cf-ray: warmup' \
    http://localhost:3000/sitemap.xml \
  | grep -oE '<loc>https://kim-bu\.uz[^<]*/(kategoriya|category)/[^<]*</loc>' \
  | sed -E 's#</?loc>##g; s#https://kim-bu\.uz##')"
 
for pass in 1 2; do
  for p in $PATHS; do
    curl -s -o /dev/null -H 'Host: kim-bu.uz' -H 'cf-ray: warmup' "http://localhost:3000$p"
  done
  sleep 3
done

Ikkita nozik joy:

  • So'rovlar Cloudflare orqali emas, to'g'ridan app'ga yuboriladi. Aks holda Cloudflare eski sahifani o'zidan berib yuboradi va app hech narsani render qilmaydi.
  • Ikki marta yuriladi. stale-while-revalidate sababli birinchi so'rov eski sahifani oladi va faqat fonda render'ni boshlaydi. Ikkinchi so'rov yangi sahifani cache'da topadi.

Cloudflare purge — bu majburiy

Cloudflare HTML'ni cache qila boshlagandan keyin yangi xavf paydo bo'ldi.

Eski HTML ichida eski build'ning JS chunk nomlari yozilgan:

Code Snippet
<script src="/_next/static/chunks/4909-fcf4f3195fcc28f8.js"></script>

Yangi konteynerda bu fayllar yo'q. Agar Cloudflare deploy'dan keyin yana bir soat eski HTML'ni bersa, foydalanuvchi sahifani ochadi, JS 404 qaytaradi va sayt buzilib ko'rinadi. Server action'lar ham xuddi shunday: Failed to find Server Action.

Shuning uchun deploy'ning oxirgi qadami:

Code Snippet
- name: Purge Cloudflare cache
  run: |
    curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE/purge_cache" \
      -H "Authorization: Bearer $CF_TOKEN" \
      -H "Content-Type: application/json" \
      --data '{"purge_everything":true}'

Token uchun faqat bitta ruxsat yetarli: Zone → Cache Purge → Purge.

Dars: CDN'da HTML cache qilsangiz, deploy pipeline'ingiz cache'ni tozalashni bilishi shart. Aks holda eski HTML + yangi static fayllar = buzilgan sahifa.


7. Kutilmagan to'siq: GHCR'dan pull uzilib qoladi

Birinchi yangi deploy muvaffaqiyatsiz tugadi:

Code Snippet
failed to copy: read tcp [2a02:...]:37474->[2606:50c0:8002::154]:443:
read: connection reset by peer

Qayta urinish — o'sha xato. Qo'lda docker pull — o'sha xato. Qiziq joyi: kichik layer'lar yuklanadi, katta layer'da uziladi.

Gipoteza: MTU

Birinchi o'ylaganim — MTU muammosi. MTU (Maximum Transmission Unit) — tarmoqda bitta paketning maksimal hajmi, odatda 1500 bayt. Agar yo'ldagi biror tarmoq kichikroq paketni o'tkazsa va "paket katta" degan ICMP xabari yo'qolib qolsa (PMTU blackhole), kichik so'rovlar o'tadi, katta ma'lumot oqimi esa tiqilib qoladi. Xuddi shu belgi.

Tekshirdim:

Code Snippet
ping -6 -c 2 -M do -s 1452 2606:50c0:8002::154   # ✓ javob keldi
curl -6 https://raw.githubusercontent.com/...     # ✓ 900 KB yuklandi

Katta paketlar o'tyapti, IPv6 ishlayapti. Gipoteza rad etildi.

Aniq sababini oxirigacha topa olmadim: curl bilan IPv6 orqali fayl yuklanadi, lekin Docker daemon'ning katta layer'ni IPv6 orqali yuklashi doim uziladi. Lekin bitta narsa aniq edi — IPv4 orqali hammasi ishlaydi.

Yechim: faqat bitta host uchun IPv4

Layer'lar pkg-containers.githubusercontent.com host'idan yuklanadi. Serverda faqat shu host uchun /etc/hostsga IPv4 manzil yozildi:

Code Snippet
IP=$(getent ahostsv4 pkg-containers.githubusercontent.com | awk 'NR==1{print $1}')
echo "$IP pkg-containers.githubusercontent.com" | sudo tee -a /etc/hosts

Tekshiruv:

Code Snippet
Status: Downloaded newer image for ghcr.io/.../kim-bu-app:3c8b50c...
real    0m49.923s

Butun serverdagi IPv6'ni o'chirish yoki MTU'ni o'zgartirishdan ko'ra bu ancha "tor" yechim: boshqa 20 ta servisga tegmaydi.

Kamchiligi ham bor: IP qo'lda yozilgan. GitHub uni o'zgartirsa, deploy docker pull bosqichida to'xtaydi. Lekin bu xavfsiz to'xtash — eski konteyner ishlashda davom etadi, sayt tushmaydi. Ehtiyot uchun pull'ga 3 martalik retry ham qo'shildi.

Dars: bir gipoteza rad etilganda uni tan olish ham natija. MTU'ga ishonib server tarmog'ini o'zgartirganimda, boshqa servislarni buzib, asl muammoni hal qilmagan bo'lardim.


8. Natijalar

Deploy log'i:

Code Snippet
Status: Downloaded newer image for ghcr.io/.../kim-bu-app:595eb21...
Status: Downloaded newer image for ghcr.io/.../kim-bu-builder:595eb21...
 Container kimbu-app Recreated
 Container kimbu-app Started
Health check passed
Pages revalidated
Warmed up 84 pages
Cloudflare cache purged

Tezlik:

HolatOldinKeyin
Oddiy sahifa (Cloudflare HIT)0.4–1 s0.2–0.35 s
Deploy'dan keyingi birinchi so'rov6–20 s0.8–1.7 s
Qidiruv (har doim render)5–11 s0.5–1.5 s
Deploy paytida server CPU7–9 daqiqa bandbuild yo'q
Sahifalar qayta render bo'lish chastotasihar 5 daqiqahar 1 soat

9. Xulosa

Qilingan o'zgarishlar ro'yxati:

Code Snippet
1. unstable_cache TTL: 300 → 3600
   → sahifalar 12 barobar kam render bo'ladi
 
2. next-intl: localeDetection: false, localeCookie: false
   → har bir URL doim bir xil javob → CDN cache qila oladi
 
3. Cloudflare Cache Rule (Respect origin + RSC istisno)
   → ko'p so'rov serverga umuman bormaydi
 
4. Docker build GitHub Actions'da, server faqat pull qiladi
   → deploy paytida live sayt sekinlashmaydi
 
5. Deploy'dan keyin warm-up
   → birinchi foydalanuvchi render kutmaydi
 
6. Deploy'dan keyin Cloudflare purge
   → eski HTML + yangi JS muammosi yo'q
 
7. GHCR uchun IPv4 + pull retry
   → deploy barqaror

Eng muhim mental model:

Code Snippet
"Sekin" → qaysi so'rov? qancha? cache'danmi?
        → o'lcha (curl -w, header'lar)
        → bo'g'inlarni ajrat (DB? render? proxy? CDN?)
        → lokal vs production solishtir
        → vaqt bo'yicha solishtir (deploy? cron? trafik?)
        → gipoteza → tekshir → rad etilsa, tan ol

Hech biri murakkab texnika emas. Qiyin qismi — to'g'ri joyni topish edi. Va buni faqat o'lchash orqali topish mumkin bo'ldi.