JavaScript Uygulamalarında Performans

Oğuz Kılıç
14 min readOct 7, 2021

--

JavaScript uygulamalarında yaşanan performans problemleri ve nedenleri üzerine bir inceleme.

unsplash — paolo candelo

Ön Söz:

Umarım bu yazı okuyanlara bir bakış açısı kazandırabilir ve geliştirdiğiniz uygulamalara, yazdığınız kodlara farklı bir perspektiften yaklaşmanıza yardımcı olabilir.

Yazı boyunca JavaScript üzerinden konuşacağız ancak genellikle Node.js üzerinden örnekler vererek konuyu server-side ekseninde pekiştirmeye çalışacağım.

JavaScript’i büyük sayılabilecek ölçeğe sahip uygulamalarda client ve server üzerinde deneyimledikten ve hatırı sayılır bir teknik mülakat tecrübesinden sonra birçok geliştiricinin aslında JavaScript’in nasıl çalıştığını bilmediğini, işlerin arka planda hangi aşamalardan geçtiğini fazla önemsemediklerini fark ettim. Bu yazıyı hazırlarken konuyu kendi deneyimlerime göre yorumladım ve belli bir süzgeçten geçirmeye çalıştım.

Daha önce JavaScript’in nasıl yorumlandığından ve render edildiğinden detaylıca bahsettiğim yazılar yazdım. Bu yazıları yazarken JavaScript engine’leri yakından inceledim, birçok yaklaşımı ve bilgiyi araştırarak edindim ve hala bilmediğim, öğrenmeye çalıştığım çok fazla konsept var. Eğer meraklı biriyseniz konunun detaylarına inmek adına bu tarz platformları incelemeniz faydalı olacaktır.

Amacım konuya derin bir bakış açısıyla bakabilmek ve sizi de bu konunun içerisine çekebilmek. Kabul ediyorum uzun bir yazı ancak vakit ayıranlar için oldukça faydalı olacağına inanıyorum. Bu yazıda birçok başlığı ele alacağım ve muhtemelen çok uzun olacağı için sıkmamak adına iki bölümde yayınlayacağım.

Performans Nedir ve Neden Önemli?

Performansı bazen bir web sitesinin açılış süresi, bazen bir algoritmanın çalışma hızı, bazen de çalışan kodun mevcut kaynaklar üzerindeki tüketim düzeyini ifade etmek için kullanıyoruz. Genellikle web için düşününce “hızı” çağrıştırdığını düşünüyorum ben. Elbette size farklı çağrışımlar yapabilir.

Nispeten daha küçük ölçekte ve hedef kitlesi daha sınırlı olan ürünlerde çoğu zaman göz ardı edilen bir konudur performans. Fakat büyük ölçekte ve her gün milyonların ziyaret ettiği bir üründe her şeyin daha az kaynak tüketip daha hızlı olmasını isteriz. Günün sonunda yaptığımız birçok şey bu temel fikir üzerine inşa edilir. Hiç kimse hantal, sürekli problem çıkaran bir ürüne sahip olmak ve kullanıcılarına bu kötü deneyimi yaşatmak istemez.

Bazen biraz daha performans kazanmak kullandığınız bir paketin versiyonunu yükseltmek kadar kolay yoldan elde edilebilir, bazense kod tabanınızda küçük-büyük ölçekte değişiklikler yapmayı gerektirecek bir maliyete neden olabilir, bazense mimariniz en baştan hatalar üzerine inşa edilmiş olabilir.

JavaScript, geliştirilen web uygulamalarının temelini oluşturuyor ve sadece tarayıcı üzerinde çalışan bir dil olmaktan çok daha fazlası. Ekosistem JavaScript’i sunucu taraflı uygulamalar geliştirmekten, mobil uygulamalar geliştirmeye kadar hemen hemen her platforma uyarlamış durumda.

JavaScript’i birçok platformda kullanmak özellikle UI açısından çok fazla avantaj sağlasa da, iş JavaScript’in alışık olduğu process’ler dışına taşınınca (server gibi) bazı dezavantajları da beraberinde getirebiliyor.

Bu noktada dilin temel yapısından biraz daha fazlasını bilmek yani nasıl çalıştığını, nasıl yorumlandığını, yazılan bir kodun etkisinin ve maliyetinin ne olduğunu öngörebilmek oldukça önemli bir konu haline gelir.

Genel Bakış

Node.js’den bahsederken genellikle hızlı olduğundan söz edilir. Çoğu geliştiriciye sorduğumuzda single thread olduğunu söyler. Ancak bu temelde doğru değil sadece biraz ezberlenmiş bir tanım olabilir. Yazdığımız bazı kodlar tek bir thread üzerinde çalışır ancak platform olarak baktığımızda I/O’dan ve farklı görevlerden sorumlu olan birkaç thread daha bulunur.

Node.js non-blocking I/O’a sahiptir ve onu bu kadar performanslı yapan bu konsepttir. Özellikle maliyetli işlemler (networking gibi) biz bir ayar yapmasak da C++ ekosistemi üzerinde çalışan Libuv tarafında işlenirler. Dolayısıyla thread bu maliyetleri bertaraf eder sadece gelen output ile ilgilenir. Bu konuyu daha detaylı olarak concurrency konusu kapsamında konuşuyor olacağız.

Node.js V8 engine üzerine inşa edilen bir platformdur. Platform diyorum çünkü kendisi bir dil değil. V8 engine JavaScript’in compile edilmesi, çalıştırılması ve bellek yönetiminin yapılması gibi birçok konuyu yönetir. Yazdığınız JavaScript kodlarını doğrudan runtime’da makine koduna compile eder. Aynı zamanda yazdığınız kodların üzerinde birtakım iyileştirmeler yapmaya çalışır.

Concurrency Konusu

Söz konusu anlık binlerce bağlantı olduğunda server-side bir uygulamadan beklediğimiz tam olarak asenkron non-blocking’e sahip olmasıdır. Böyle bir ölçekte sadece I/O değil sunucu üzerinde çalışan tüm kodun non-blocking ve asenkron olmasını oldukça önemseriz. Node.js’in platform olarak öne çıktığı konuların başında non-blocking I/O konsepti gelir.

kaynak: itnext.io

Konunun devamını anlayabilmek için önce bu konsepti anlamamız gerekli.Yukarıda ki görseli ve farklı versiyonlarını birçok yerde gördüğünüzü tahmin ediyorum.

V8 engine üzerinde heap ve call stack adında iki bölüm daha bulunuyor. Heap alanında memory allocation işlemleri gerçekleştiriliyor. Call Stack’i ise stack frame adındaki girdilerin LIFO yapısında push ve pop edildiği ve o an nerede olduğumuzu kaydeden bir veri yapısı olarak özetleyebiliriz.

Event Queue, main thread tarafından execute edilmeyi bekleyen işlevlerden meydana gelir. JavaScript tüm event’leri eş zamanlı olarak ele alır fakat event queue aracılığıyla bu event’leri sırayla işler.

Event loop ve call stack main thread üzerinde çalışırlar bu nedenle özellikle event loop’u engelleyecek durumlardan olabildiğince kaçınmamız hayati derecede önemli.

Yazdığımız bir fonksiyonu çağırdığımızda bu fonksiyon, scope’unda bulunan değerlerle call stack’e girer, event loop stack’te bulunan stack frame’i event queue’a taşır, thread’de çalışan işlem tamamlanana kadar burada bekletilir. Thread müsait olduğunda da işlem yürütülür.

Non-Blocking konusunu özet olarak bu şekilde izah edebiliriz. Ancak cpu intensive görevler main thread’i bloke eder dolayısıyla Non-Blocking konusundan bahsederken aslında I/O operasyonunu kastediyoruz.

Örneğin, server-side rendering yapmak istediğinizde rendering işlemi cpu intensive bir görevdir ve main thread’i bir süre bloke eder. Bir HTTP isteği yapıldığında yoğun bir hesaplama maliyeti oluşur ve önemli bir zaman bu hesaplamaya harcanır. Bu durum event loop’un diğer tüm istekler için engellenmesi anlamına gelir.

Kötü durumda bir işlem parçacığınız varsa optimize edilmesi gerekir. Geliştiriciler olarak çoğu zaman bilinçsizce NPM paketlerini projelerimize dahil ediyoruz ve bize nasıl bir maliyet getirdiğini hesaba katmıyoruz. Paket seçiminde tek kriter paketin bundle’a getireceği ek byte’lar olmamalı. Bilinçsizce kullandığınız paketler event loop’u engelleyebilir ve belli görevleri olan metodlarınızda yavaşlığa neden olabilir. Bu tarz problemleri nasıl tespit edebileceğimizle ilgili bazı ipuçlarını yazının ikinci bölümünde paylaşacağım.

Yukarıda paylaşılan görselde ki libuv katmanının işlevini inceleyelim.

Libuv, dns.lookup(), fs, fs.readFile(), crypto modülü gibi cpu-intensive işlemleri, worker thread pool olarak adlandırılan ayrı bir yerde yürütür. Bunun nedeni event loop’un bu tür işlemler için uygun olmaması ve main thread’i engellemesi olarak ifade edebiliriz kısaca.

Varsayılan olarak libuv, 4 tane thread sunuyor bize ancak bu sayı bir flag geçilerek artırılabiliyor. Yine de cpu tüketen işlemlerden olabildiğince kaçınmak gerektiğini unutmamalıyız. Thread pool size gelişi güzel artırılabilecek bir değer değil. Maksimum 1024'e kadar artırılabilir fakat sayıyı uygulamanızın üzerinde çalıştığı makinenin işlemci özelliklerine uygun olarak artırmanız gerekli.

Bu duruma varsayımsal yaklaşmak yani sabit bir sayı tanımlamak yerine Node.js’in OS modülünü kullanabilirsiniz;

Örnek:

OS.cpus().length // => 8
process.env.UV_THREADPOOL_SIZE = OS.cpus().length

Özetle, request per second(RPS > 1 saniyede aldığınız istek sayısı) metriğini hesaplarken kullandığımız formül aşağıdaki gibidir;

THREADPOOL_SIZE x (saniye / hesaplama maliyeti) = RPS değerinizdir.

Tüm istekleriniz yoğun cpu hesaplaması gerektiren isteklerden oluşmayabilir yukarıdaki formül bu tür istekler için daha uygun.

Garbage Collection Konusu

JavaScript doğası gereği garbage collected bir dildir ve memory konusunda bir şey yapmanız gerekmez. Bu da JavaScript ekosisteminde kod yazan geliştiricilerin bu konuyu fazla önemsememesi ile sonuçlanabiliyor. Client tarafında da önemli bir konu, ancak server tarafında kaynak tüketimi açısından hayati öneme sahip.

Yine çoğu JavaScript geliştiricisinin uzak olduğu konulardan bir tanesi bellek yönetimi. Bugüne kadar çok sayıda mülakata katıldım ve birçok adaya temel konularla ilgili sorular yöneltmeye çalıştım. Birçoğu JavaScript’te yazılan değişkenlerin ve tiplerin bellekte nasıl tutulduğunu ve yönetildiğini bilmediğini farkettim.

Bu kadar derinlemesine bilgi gerçek hayatta nerede işimize yarayacak diyenleri duyuyor gibiyim. Orta-büyük ölçekte trafiğe maruz kalan node.js uygulamaları ile çalışıyorsanız ve dilin temel prensiplerini, bellek üzerinde nasıl depolandığını, işlendiğini bilmiyorsanız düşük performansın ve fazla kaynak tüketiminin nedenini anlamakta ve çözüm üretmekte zorlanabilirsiniz.

V8 üzerinde GC işleminin nasıl yürütüldüğünü anlamaya çalışarak devam edelim.

Bellek Yönetimi

Belllek yönetimi JavaScript engine’ler tarafından yönetilir ve bu işlem otomatik olarak yürütülür. Özetle bellek yönetimi dediğimiz şey kullanılmayan bellek bölümlerini boşaltmak için çalışan bir dizi işlemi ifade eder.

GC öncesi bellek

Belleğin kullanılıp kullanılmadığını anlamanın en bilinen yolu referansları izlemektir. Yani herhangi bir nesneye referansı olmayan nesneler işaretlenerek toplanır ve temizlenir.

GC sonrası bellek

Genellikle GC çalıştığı sırada performansla ilgili endişelerimiz artar. Çünkü GC sırasında bir miktar cpu işlem gücü tüketilir. Bu nedenle GC’nin çalışma şekli önemlidir.

V8'de concurrent marking adı verilen bir garbage collection tekniği uygulanır. Bu sayede canlı nesneleri bulup işaretlemek için heap alanını tararken JavaScript uygulamasının çalışmaya devam etmesi sağlanır. Eskiden “stop the world sweeping” denilen bir garbage collection tekniği uygulanıyordu ve bu tekniğin temel problemi yürütülen işlemin durdurulmasıydı. Daha sonra uygulama idle olduğunda devreye giren yeni bir teknikten faydalanmaya başlandı.

kaynak: v8.dev

Node.js versiyon 10'dan itibaren varsayılan olarak GC bu şekilde çalışıyor.

V8 referans sayımını sürekli olarak kontrol etmek yerine bunu periyodik olarak yapar.

Heap, temelde new space ve old space olarak iki bölümden meydana gelir. New space tahmin edebileceğiniz üzere yeni memory allocation’ların(mallloc) yapıldığı yer. Burada yer alan canlı nesnelere Young Generation ismi veriliyor ve canlı nesne olarak işaretlenenler daha sonra Old Space’e taşınıyor. Burada yaşayan nesnelere de Old Generation ismi veriliyor.

Bu kavramları bilmek önemli çünkü uygulamanızı monitor ettiğinizde metrikleri doğru okumanıza ve anlamlandırmanıza yardımcı olur yani problemi debug etmenize katkı sağlar. Aksi taktirde memory leak gibi problemlerin nedenini anlamakta zorlanabilirsiniz ve production ortamında başınıza gelmesini en istemeyeceğiniz konulardan biriyle karşı karşıya kalabilirsiniz.

Gelin bu konunun üzerine biraz daha gidelim.

Memory Leak’den (bellek sızıntısı) production ortamındaki bir uygulamanın kullanım oranında anormal bir artış olmamasına rağmen devamlı artan bellek tüketiminin yaşandığı bir senaryoyla karşılaştığımızda şüphelenebiliriz.

Örneğin, bellek tüketiminiz normal seyrinde devam ederken yaptığınız bir deployment sonrası tüketim miktarının düzenli olarak arttığını gözlemlediğinizde muhtemelen olası bir memory leak’e bakıyor olabilirsiniz. Burada bir npm paketini güncellemiş de olabilirsiniz, kod tabanınızda leak’e neden olabilecek bir değişiklik yapmış da olabilirsiniz.

örnek leak

Şimdi dedektif şapkamızı takalım.

Memory Leak’e neden olabilecek birçok etken olabilir. Böyle bir durumla karşılaşıldığında kodda ilk incelenmesi gereken noktalar Closure’lar, global değişken tanımlamaları ve cache kullanılan alanlar olabillir.

örnek: 1

Yukarıdaki gibi bir örnekte başında herhangi bir tanımlayıcı ifade bulunmayan değişken kullanımları doğrudan hoisting’e neden olur. Normal şartlarda eğer const x = 5 gibi bir ifade ile tanımlasaydık fonksiyon çağrıldıktan sonra ölmüş olacaktı. Bu en basit haliyle tipik bir memory leak durumudur. Bu yüzden fonksiyon içerisinde ki değişken tanımlamalarını yukarıdaki gibi yapmaktan uzak durmalıyız.

Yine yukarıdaki örnek üzerinden gittiğimizde global değişkenler için GC yapılmayacağını eğer büyük değerler tutuluyorsa daha fazla bellek tüketimine neden olacağını unutmamalıyız.

Eğer illa global değişken kullanmanız gerekiyorsa kullandıktan sonra değişkenin değerini null’a eşitleyerek GC’in ilgili bellek alanını serbest bırakmasını sağlayabilirsiniz.

GC bu değişkenlerin işgal ettikleri alanı referans sayımı kuralından dolayı serbest bırakmayacaktır ve memory leak dediğimiz problem baş gösterecektir. Bellek bir süre sonra tamamen dolduğunda uygulamanızın performansı önemli ölçüde etkilenecektir.

Sınırsız kaynaklara sahip olmadığınızı düşünürsek bu problemi kaynak artırarak çözmek sadece sorunu halının altına süpürmenizi sağlar ve size zaman kazandırır. Bu nedenle uygulama belleği üzerinde cache tutmak yerine Redis, Memcached gibi çözümleri tercih etmelisiniz.

Farklı nedenlerden dolayı in-memory cache kullanmanız gerekiyorsa LRU algoritmasıyla in-memory cache’i daha efektif bir şekilde yönetmeyi deneyebilirsiniz. LRU algoritmasını kısaca özetlemek gerekirse önbelleğe alınacak öğeleri en son kullanım sırasına göre düzenler. Yani en son kullanılandan en az kullanılana doğru sırayla önbelleğe alır. Temelde linked-list ve hash map kullanılarak oluşturulur.

Time complexity açısından önbellekteki bir öğeye erişmeyi O(1) olarak ifade edebiliriz. Her öğeye eriştiğimizde yine önbelleğin güncellenmesi gerekir çünkü kullanım oranı sıralamayı düzenlemeyi gerektirir. Burada O(n)’lik bir space complexity’den söz ediyoruz. Dolayısıyla redis gibi çözümler performans açısından size daha fazlasını sunabilir.

Diff Algoritmaları ve Server-Side Rendering

virtual dom

SSR mekanizmaları genel olarak JavaScript ekosisteminde yer alan framework’lerin temel problemlerinden bir tanesi. Çünkü çok basit tabirle bir dili farklı bir dile veya syntax’a çeviriyoruz ve bunu dinamik olarak her istek için tekrar tekrar ele alıyoruz.

Konunun ilk bölümlerinde bu tip operasyonların cpu intensive görevler olduğundan bahsetmiştim. Dolayısıyla SSR yapmak doğrudan main thread’i bloklayan ve cpu tüketimini fazlasıyla artıran bir işlemler bütününü ifade ediyor bizim için.

SEO veya kullanıcı deneyimi gibi kaygılarınız olduğunda SSR karşımıza problemleri olan bir çözüm olarak çıkıyor. Elbette SSR için farklı çözümler üretebilir veya mevcut çözümlerden bir tanesini benimseyebilirsiniz. Ancak çoğu şirketin bu operasyonu devralacak ve maintain edebilecek bir ekibi olamıyor. Bu durum bizi next, nuxt gibi hazır platformlara doğru yöneltiyor.

Problem nedir?

SSR yaparken TTFB (time to first byte) metriklerinizde anormal artışlar olması doğaldır. React üzerinden örneklemek gerekirse bunun nedenlerinden bir tanesi renderToString() metodunun sync çalışması ve single-thread olmasıdır.

Diğer yandan Node.js’i düşündüğümüzde concurrent olan binlerce bağlantıya aynı anda verimli yanıtlar verebilir. Çünkü node.js non-blocking I/O’a sahiptir. Ancak gerçek dünyada işler pek sanıldığı kadar kolay değil. SSR ile kullanıcılara sunduğunuz performansı sunucu tarafında kaybetmeye başlarsınız bu kaçınılmazdır. SSR yoğun bir hesaplama maliyeti ile beraber gelir.

Gelen istekler önce event-loop ile sıraya konulur ve thread boşaldığında sırada bekleyen istek boş olan thread’e alınır. Bu sayede daha fazla isteğe herhangi bir bloklama olmadan yanıt verilebiliyor.

standard I/O

SSR tarafında ise yapılan hesaplama işlemlerini maalesef paralel olarak çalıştıramıyoruz. Node.js büyük miktarlarda async I/O’u paralel olarak işleyebilir ancak bu taleplerin hesaplanan kısımları bir süre sonra CPU üzerinde latency’e oluşturmaya başlar.

compute

Eş zamanlı SSR istekleri ve oluşan hesaplama maliyeti bir darboğaz oluşturur ve diğer istekler ertelenmeye başlar. Eş zamanlılık devam ettiği müddetçe işler daha kötüye gitmeye başlar. Kullanıcı trafiği yoğun olmayan web uygulamalarında biraz kaynak artırarak sorunu aşmak mantıklı gelebilir. Ancak yüksek trafikli web uygulamaları için bu adeta bir kaosa dönüşür. Problemi gidermek için devamlı kaynak takviyesi yapmaya başlarsınız.

multiple requests & compute

Birbirini erteleyen eş zamanlı istekler ve bu isteklerin neden olduğu latency karşınıza büyük bir engel olarak çıkar. Günün sonunda yük altında çok daha düşük RPS değerleri ile çok daha yüksek latency’e sahip bir web uygulamasıyla baş başa kalırsınız.

Problemi aşmak için neler yapabiliriz?

SSR yapmaya karar verdiğinizde ilk olarak cluster mode’u aktif ederek başlayabilirsiniz. Cluster mode sayesinde birden fazla CPU çekirdeğinden yararlanmaya başlayabilirsiniz. Eş zamanlı istekleri cluster mode ile paralel olarak ele almaya başlayarak bağımsız süreçler yürütebilirsiniz. Unutmadan her node’un istek devam ettiği süre boyunca bloklandığını unutmamalıyız.

Eğer kubernetes üzerinde uygulamayı çalıştırıyorsanız düşük cpu ve ram değerlerine sahip çok sayıda minik podlar kaldırabilirsiniz. Diğer bir seçenek ise daha az sayıda pod ve podlarda daha yüksek cpu, ram miktarı ile her pod içerisinde clustering yapmak yani child process’ler yaratmak ve yükü bu instance’lara cluster modülü ile dağıtmak. Kendi deneyimlerime göre ikinci yolun daha performanslı olduğunu gözlemledim.

Uygulamanızın yapısına en uygun çözümleri yük testlerinden faydalanarak uygulayabilirsiniz. Yük testi yaparken sadece load test değil, spike ve soak testleri de koşarak uygulamanızı analiz etmeniz önemli. Elbette gözlemlenebilirlik konusu burada oldukça öne çıkıyor. Bu konuyla ilgili bir sonraki yazımda detaylı bir paylaşım yapmayı düşünüyorum.

Cluster Mode:

Cluster mode için kod yapınızda herhangi bir değişiklik yapmadan pm2 kullanmak seçeneklerden bir tanesi. Pm2 ile kolayca node uygulamanızı ölçekleyebilir ve diğer güzel özelliklerinden faydalanabilirsiniz. PM2 ile ilgili tek olumsuz yorumum belli bir memory alanını sürekli olarak allocate ediyor olması ve yaptığım benchmark’larda instance’lara istekleri dağıtırken native cluster modülüne göre biraz yavaş kalıyor olması.

Dahili load-balancing özelliği ile herhangi bir kod değişikliği yapmadan mevcut tüm CPU’larda uygulamanızın ölçeklendirilmesini sağlayabilirsiniz. Burada hangi node process’lerinin hangi request’leri alması gerektiği ile ilgili akıllı kararlar verilmesi gerekiliyor elbette.

Cluster modülü tüm request’leri round-robin algoritması ile dağıtıyor. Yani her process’e bir zaman veriyor ve o zaman sonunda hala ilgili işlem CPU üzerinde işini bitirmemişse “sen çok meşgul ettin sırayı senden sonrakine veriyorum” diyor. Bu sayede overload bir nebze engelleniyor.

Özetle PM2 ile tüm CPU kaynağınız kadar instance yaratabilir ve eş zamanlı SSR işlemleri sırasındaki hesaplamaları daha fazla sayıda işleyerek latency’i düşürebilir ve CPU üzerindeki darboğazı biraz daha hafifletebilirsiniz.

Sunucu üzerindeki SSR yükünü hafifletmek için render edilen çıktının ön belleğe alınması önemli bir konu. Genellikle burada ilk akla gelen varnish gibi cache mekanizmalarıdır. Eğer anlık çok değişken içerikleriniz yoksa varnish kullanmak iyi bir seçenek olabilir elbette. Ancak kullanıcılara özel fiyatlar, kampanyalar veya tamamen dinamik ve sürekli değişken içerikler sunduğunuz bir web uygulamasına sahipseniz üstelik SEO çekinceleriniz varsa varnish gibi cache mekanizmaları ideal çözüm olmayacaktır.

Next.js kullanıyorsanız ISR (incremental static regeneration) güzel bir çözüm olabilir. ISR ile build time’da en çok trafik alan sayfalarınızı render edebilir ve vereceğiniz bir re-validation süresi ile akıllı bir şekilde update edebilirsiniz. En çok trafik alan sayfalar diyorum çünkü render edeceğiniz sayfa sayısının artması build süresi olarak size geri dönecektir. Varnish gibi cache çözümlerinde url kombinasyonunun arttığı durumlarda hit oranları düşük olabilir ancak ISR ile daha yüksek hit oranlarına ulaşabilirsiniz ve doğrudan bir html serve edebilirsiniz.

Build time dışında bıraktığınız sayfalarınız için ilk istekleriniz sunucu üzerinde render edilecek ancak bir sonraki istekler belirttiğiniz revalidation süresi boyunca statik html olarak sunulacaktır. Bu sayede sunucu yükünüzü önemli ölçüde hafifletebilirsiniz.

Sadece cluster modülünü aktif etmek SSR sırasındaki hesaplamaların CPU üzerindeki maliyetini hafifletmek için tek başına yeterli değil. Burada biraz daha farklı bir rendering mekanizması kurgulamaya ihtiyacımız var.

Streaming SSR

React’in renderToString() metodunun sync çalıştığından ve single-thread olduğundan bahsetmiştik. Bu nedenle yoğun trafik altında ki bir web uygulamasında renderToString() kullanmak bizim için oldukça maliyetliydi. React 16 ile beraber hayatımıza renderToNodeStream() metodu girdi ve streaming ssr kavramı ile tanışmış olduk.

Node.js’teki streaming konusunu ve bunun SSR için kullanılmasının performans olarak nasıl bir avantaj sağlayabileceğinden biraz bahsetmeye çalışacağım.

Node.js gibi event-based bir platformda I/O için en verimli yol üretilen çıktıyı kullanılabilir olduğu anda tüketmeye başlamaktır. Yani bizim örneğimizde tüm react uygulamasının render olmasını beklemek yerine render olan html parçalarını yani chunk’ları client’a işlenmek üzere göndermeye başlamak TTFB süresini oldukça kısaltacaktır.

Büyük bir html dosyasını ağ üzerinde göndermek yerine chunk chunk göndermek kaynak tüketimi açısından oldukça verimli bir yöntem. Eğer critical css gibi yöntemler uyguluyorsanız bu durumda kritik asset’inizin tarayıcıya daha hızlı ulaşması anlamına yani tarayıcının paint süresinin de oldukça kısalması anlamına geliyor.

renderToNodeStream() aynı renderToString() gibi bir html string çıktısı verir ancak bunu yapan bir readable stream döndürür.

Yukarıdaki çözüm önerilerine ilave olarak kod üzerinde yapılacak optimizasyonlar veya işleri en baştan sıkı tutmak size yardımcı olabilir. Örneğin, React component’larında dom seviyesinde derinlik arttıkça kompleksite artar ve ilgili component’ın sunucu üzerindeki render süresi artar.

Olabildiğince basit ve derinliği az olan dom elemanları yaratmak üzere component tasarlanması performansınıza destek olabilir.

Dinamik olarak sürekli değişmeyen alanlar için component seviyesinde cache çözümleri düşünebilirsiniz veya Micro frontend gibi mimariler ile daha küçük parçaları sunucu üzerinde işleyebilir, dinamik değişmeyen alanları doğrudan cache’leyerek html olarak serve edebilirsiniz.

Diğer bir yol ise ESI(edge side includes) gibi content composition çözümlerini denemek. Bir araca bağımlılık yaratması konusunda endişelerim olsa da siz denemek isteyebilirsiniz.

Konuyu toparlayacak olursak JavaScript client üzerinde iyi çalışan ancak sunucu tarafında belirli bir performans challange’ıyla birlikte gelen bir dil.

Burada dilin probleminden ziyade dili tasarlanma amacına uygun kullanmayarak yoğun cpu işlemleri üzerinde koşturmak problemin asıl tanımı sanırım. Maalesef şu anki durumda JavaScript kütüphanelerini en rahat ve yönetilebilir şekilde Node.js sayesinde sunucu üzerinde render edebiliyoruz. Eğer cache çözümlerinden biriyle ilerleyebilecek bir domain yapısına sahipseniz endişelenmeniz gereken pek bir şey yok. En kötü ihtimalle bile kısa süreli örneğin 30 saniye 1 dakika gibi süreler sunucu yükünüzü oldukça hafifletecektir.

React gibi kütüphanelerin virtual dom gibi harika özelliklerinin sunucu tarafında getirdiği performans overhead’leri karşımıza çıkan bir diğer büyük challenge’ı meydana getiriyor. Burada bazı hack çözümler uygulanabilir. Uygulama ağacı üzerinde babel ile bazı müdahalelerde bulunabiliriz. Bu noktada over engineering’e doğru ilerlediğinizi unutmamalısınız.

Tüm bunlar çok dikkatli yapılması gereken ve derinlemesine bilgi gerektiren konular. Örneğin server’da virtual dom olmadan renderToString yapmak gibi çözümler ile performansta bazı kazanımlar elde edebilirsiniz. Her getirinin bir götürüsü olduğunu unutmadan elbette.

Son bir husus ise API performansı. Servisten aldığınız bulk datayı doğrudan server-side rendering yaparken kullanmak, tüm veriyi props olarak uygulama ağacınızdan içeri aktarmak bir dezavantaj yaratır. Bu dezavantaj DOM boyutunuzun ve işlemeniz gereken JSON boyutunun artması nedeniyle rendering sürenizin uzaması gibi sonuçlar doğuracaktır. Servisten sadece ihtiyacınız olan veriyi olabildiğince hızlı bir şekilde almalısınız. GraphQL gibi çözümleri bu nedenle oldukça seviyorum.

Bir sonraki yazıda performans metriklerini nasıl oluşturacağımızı, gözlemlenebilirliğin neden önemli olduğunu, nasıl takip edebileceğimizi, memory leak gibi performans problemlerinin tespitini nasıl yaptığımızdan ve hangi araç gereçleri kullandığımızdan bahsederek devam edeceğiz.

Yazının ikinci bölümüne aşağıdaki bağlantıdan gidebilirsiniz.

Bazı Faydalı Kaynaklar:

https://pm2.io/

https://nodejs.org/api/cluster.html

https://www.smashingmagazine.com/2021/04/incremental-static-regeneration-nextjs/

https://www.oreilly.com/library/view/the-garbage-collection/9781315388007/

https://nodejs.org/en/docs/guides/dont-block-the-event-loop/

https://www.freecodecamp.org/news/node-js-child-processes-everything-you-need-to-know-e69498fe970a/

https://levelup.gitconnected.com/hunting-memory-leaks-in-server-side-rendered-react-application-311c6306d552

İnceleme için Özer Yılmaztekin, Barış Güler’e teşekkürler.

--

--

Oğuz Kılıç

developer @trendyol, ex @eBay, sci-fi addict. JavaScript, Frontend, Software Architecture