JavaScript Fetch API: GET, POST, response.ok, Zaman Aşımı ve CORS'a Giriş
Fetch API ile veri çekip gönderiyor; response.ok ile 404'ün neden catch'e düşmediğini, POST/PUT/DELETE'i, AbortSignal.timeout ile zaman aşımını, CORS'u ve yükleniyor/hata durumlarını ekranda göstermeyi görüyoruz.
Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.
Önce Tahmin Et
Var olmayan bir kaynağı istiyoruz: 9999 numaralı yazı yok. Konsola hangisi yazılır, then mi catch mi?
fetch("https://jsonplaceholder.typicode.com/posts/9999")
.then((response) => console.log("then:", response.status))
.catch(() => console.log("catch"));
Okumaya devam etmeden önce bir tahmin yap. Bu soru, fetch ile yazılan kodda en sık rastlanan hatalardan birinin tam kalbinde duruyor. Cevabı response object'ini tanıdıktan sonra açıklayacağız.
İlk İstek: GET ve JSON
Şimdiye kadar asenkron işleri setTimeout ile taklit ettik. Artık gerçek bir sunucuyla konuşacağız. Bunun için tarayıcının sunduğu fetch fonksiyonunu kullanacağız. Örneklerde, test amaçlı sahte veri sunan ve tarayıcıdan gelen isteklere açık olan JSONPlaceholder service'ini kullanıyoruz.
Konsolu https://jsonplaceholder.typicode.com sayfasında aç; bazı siteler kendi güvenlik ayarlarıyla başka adreslere istek atılmasını engelleyebiliyor, bu sayfada öyle bir sorun yok. Sonra bunu yapıştırıp dene:
const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
const post = await response.json();
console.log(post.title);
console.log(post);
Konsol top-level await'e izin verdiği için burada fonksiyon yazmadık. Gerçek bir sayfada bu satırlar bir async fonksiyonun içinde durur. Örnekleri arka arkaya yapıştırırken "has already been declared" hatası alırsan sayfayı yenileyip devam et; aynı isimli const'u ikinci kez tanımlamaya çalışıyorsun.
Akış iki adımlı ve ikisi de asenkron. fetch, bir promise döndürür ve bu promise sunucudan cevabın başlığı geldiği anda bir Response object'iyle sonuçlanır. Ama gövde, yani asıl veri, o anda henüz tamamen gelmemiş olabilir. response.json() gövdeyi sonuna kadar okur, JSON olarak çözer ve bunu da bir promise olarak verir. Bu yüzden iki tane await var.
Bir kargo düşün. Kurye kapına geldiğinde önce paketin üstündeki etikete bakarsın: kimden gelmiş, hasarlı mı. Bu, Response'un kendisi. Paketi açıp içindekini çıkarmak ise ayrı bir iş; o da json().
Response Object'i: status, ok ve Gövde
Elindeki Response object'i, cevap hakkında her şeyi taşır:
const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
console.log(response.status); // 200
console.log(response.ok); // true
console.log(response.headers.get("content-type")); // "application/json; charset=utf-8"
status, HTTP durum kodudur. Kabaca 2xx başarı, 3xx redirect, 4xx istemci hatası (404 bulunamadı, 401 yetkisiz), 5xx sunucu hatası demektir. ok ise kısa bir kestirme: durum kodu 200 ile 299 arasındaysa true, değilse false.
Gövdeyi okumak için birkaç method var. JSON için json(), düz metin ya da HTML için text(). Hepsi promise döndürür ve önemli bir kural var: gövde yalnızca bir kez okunabilir. Önce text() sonra json() çağırırsan ikincisi TypeError ile reddedilir. Paket bir kez açılır.
404 Neden catch'e Düşmez?
Baştaki sorunun cevabı: then: 404. Şaşırtıcı ama doğru. fetch'in promise'i sunucu hata kodu döndürdüğünde reddedilmez. Sunucu "bu yazı yok" dedi ve bu da bir cevap. İstek gitti, cevap geldi; fetch'in gözünde iş başarıyla tamamlandı.
fetch, sadece isteğin kendisi başarısız olduğunda reddedilir: internet bağlantısı yoksa, adres hatalıysa, CORS engeline takıldıysa ya da istek iptal edildiyse. Yani hata kodlarını kontrol etmek senin işin. Bunu her seferinde tekrar yazmamak için küçük bir yardımcı fonksiyon kurmak iyi bir alışkanlık:
async function getJSON(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${url}`);
}
return response.json();
}
try {
const post = await getJSON("https://jsonplaceholder.typicode.com/posts/9999");
console.log(post);
} catch (error) {
console.error(error.message); // "HTTP 404: https://jsonplaceholder.typicode.com/posts/9999"
}
Artık hem ağ hataları hem de HTTP hataları aynı catch'e düşüyor. Bu yazının geri kalanında getJSON'u kullanacağız.
Veri Göndermek: POST, PUT, DELETE ve Header'lar
fetch'e ikinci argüman olarak bir ayar object'i verebilirsin. Varsayılan method GET'tir; veri göndermek için method'u, header'ları ve gövdeyi belirtirsin:
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Learning the Fetch API",
body: "My first POST request",
userId: 1,
}),
});
console.log(response.status); // 201
console.log(await response.json());
// { title: "Learning the Fetch API", body: "My first POST request", userId: 1, id: 101 }
Üç şeye dikkat et. Object'i doğrudan veremezsin, daha önce gördüğümüz JSON.stringify ile string'e çevirmelisin. Content-Type header'ı sunucuya "sana JSON gönderiyorum" der; bunu unutursan pek çok sunucu gövdeyi anlamaz. Son olarak 201 kodu "oluşturuldu" demektir. JSONPlaceholder sahte bir service olduğu için veriyi gerçekten kaydetmez, sadece kaydetmiş gibi cevap verir.
Güncelleme ve silme de aynı kalıpla yapılır:
// Kaydın tamamını değiştir
const updated = await fetch("https://jsonplaceholder.typicode.com/posts/1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: 1, title: "New title", body: "New content", userId: 1 }),
});
console.log(updated.status); // 200
// Kaydı sil
const deleted = await fetch("https://jsonplaceholder.typicode.com/posts/1", {
method: "DELETE",
});
console.log(deleted.status); // 200
Header'lar istekle ilgili ek bilgi taşır. Accept ile hangi formatta cevap beklediğini, Authorization ile kimliğini bildirirsin. Burada önemli bir uyarı: tarayıcıda çalışan kodu herkes görebilir. Gizli bir API anahtarını front-end koduna gömersen, sayfayı açan herkes DevTools'ta onu bulabilir. Kullanıcının oturum token'ı gibi bilgilerin nerede saklanacağını Web Storage ve cookie yazısında konuşacağız.
İptal ve Zaman Aşımı: AbortController
Kullanıcı bir aramayı yazarken her harfte yeni istek atıyorsan, eski istekleri iptal etmek istersin. Ya da bir sunucu cevap vermiyorsa sonsuza kadar beklemek istemezsin. İkisinin aracı da aynı: bir AbortSignal.
const controller = new AbortController();
setTimeout(() => controller.abort(), 50); // 50 ms sonra vazgeç
try {
const response = await fetch("https://jsonplaceholder.typicode.com/photos", {
signal: controller.signal,
});
console.log(response.status);
} catch (error) {
console.log(error.name); // büyük olasılıkla "AbortError"
}
AbortController bir uzaktan kumanda gibi: signal'i fetch'e verirsin, abort() düğmesine bastığında istek kesilir ve fetch'in promise'i AbortError adlı bir hatayla reddedilir. "Büyük olasılıkla" dedik çünkü hızlı bir bağlantıda cevap 50 milisaniyeden önce gelebilir.
Sadece zaman aşımı istiyorsan daha kısa bir yol var. AbortSignal.timeout(), verdiğin süre dolunca kendiliğinden iptal olan bir sinyal üretir ve Nisan 2024'ten beri tüm güncel tarayıcılarda çalışıyor:
try {
const response = await fetch("https://jsonplaceholder.typicode.com/photos", {
signal: AbortSignal.timeout(5000),
});
const photos = await response.json();
console.log(photos.length); // 5000
} catch (error) {
if (error.name === "TimeoutError") {
console.error("The server didn't answer within 5 seconds");
} else if (error.name === "AbortError") {
console.error("The request was cancelled");
} else {
console.error("Network error:", error.message);
}
}
Fark ettiysen zaman aşımında hatanın adı AbortError değil, TimeoutError. Bu ayrım işine yarar: kullanıcıya "işlem iptal edildi" ile "sunucu yavaş, tekrar dene" arasında farklı mesajlar gösterebilirsin.
CORS'a Giriş
Bir web sayfası, kendi adresinden, yani kendi origin'inden farklı bir adrese istek attığında tarayıcı bir güvenlik kuralı uygular: same-origin policy. Varsayılan olarak, başka bir origin'den gelen cevabı JavaScript'in okumasına izin vermez. Aksi hâlde açtığın herhangi bir site, arka planda bankanın sitesine istek atıp oturumun açıkken senin hesap bilgilerini okuyabilirdi.
CORS (Cross-Origin Resource Sharing), sunucunun bu kurala kontrollü bir istisna tanımasını sağlar. Sunucu cevabına Access-Control-Allow-Origin gibi header'lar ekleyerek "şu origin'lerden gelen sayfalar beni okuyabilir" der. JSONPlaceholder bu header'ları gönderdiği için onu her sayfadan kullanabiliyoruz.
Akılda tutman gereken iki şey var. Birincisi, CORS tarayıcının uyguladığı bir kuraldır ve izni sunucu verir; front-end kodunda yazacağın hiçbir şey onu aşamaz. İkincisi, CORS engeline takılan bir fetch, ayrıntı vermeyen bir TypeError ile reddedilir. Gerçek sebebi kodun göremez; sadece konsoldaki kırmızı uyarıda okursun. "Neden fetch'im patlıyor?" diye düşündüğünde önce konsola bak.
Bir de tarihî not: fetch'ten önce bu işler XMLHttpRequest ile yapılırdı. Eski kodlarda hâlâ karşına çıkar ama callback tabanlı ve kullanımı zahmetlidir. Yeni kodda fetch kullan.
Yükleniyor ve Hata Durumlarını Göstermek
Konsolda çalışmak öğrenmek için güzel, ama kullanıcı konsol görmez. Bir isteğin üç hâli vardır: yükleniyor, başarılı, hatalı. İyi bir arayüz üçünü de gösterir. Aşağıdaki dosyayı kaydedip tarayıcıda aç:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Post list with fetch</title>
</head>
<body>
<label>User id: <input id="userId" type="number" value="1"></label>
<button id="load">Load posts</button>
<p id="status"></p>
<ul id="list"></ul>
<script>
const button = document.querySelector("#load");
const statusEl = document.querySelector("#status");
const list = document.querySelector("#list");
async function getJSON(url) {
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!response.ok) {
throw new Error(`The server returned ${response.status}`);
}
return response.json();
}
button.addEventListener("click", async () => {
const userId = document.querySelector("#userId").value;
button.disabled = true;
statusEl.textContent = "Loading...";
list.replaceChildren();
try {
const posts = await getJSON(
`https://jsonplaceholder.typicode.com/posts?userId=${userId}`
);
if (posts.length === 0) {
statusEl.textContent = "This user has no posts.";
return;
}
for (const post of posts) {
const li = document.createElement("li");
li.textContent = post.title;
list.append(li);
}
statusEl.textContent = `Found ${posts.length} posts.`;
} catch (error) {
statusEl.textContent = error.name === "TimeoutError"
? "The server is too slow, please try again."
: `Something went wrong: ${error.message}`;
} finally {
button.disabled = false;
}
});
</script>
</body>
</html>
Butonu istek sürerken devre dışı bırakıyoruz ki kullanıcı arka arkaya tıklayıp on istek atmasın. Başlıkları innerHTML yerine textContent ile yazıyoruz; sunucudan gelen veriye hiçbir zaman HTML olarak güvenme, Temel seride konuştuğumuz XSS riski burada da geçerli. finally ise sonuç ne olursa olsun butonu geri açıyor. Bir de interneti kapatıp dene, ya da id olarak 999 yaz ve boş liste mesajını gör.
Sık Düşülen Tuzaklar
Birincisi, baştaki soruda gördüğümüz gibi response.ok'u kontrol etmemek. 404 ya da 500 dönen bir cevabı başarılı sanıp gövdesini işlemeye çalışırsın; çoğu zaman da anlamsız bir hata mesajıyla karşılaşırsın, çünkü sunucunun hata sayfası beklediğin JSON değildir.
İkincisi, json()'u beklemeyi unutmak. const data = response.json() yazarsan elinde veri değil, bir promise olur.
Üçüncüsü, POST'ta gövdeyi JSON.stringify ile çevirmemek ya da Content-Type header'ını koymamak. Object'i doğrudan body'ye verirsen sunucuya "[object Object]" diye bir metin gider.
Dördüncüsü, CORS hatasını front-end'de çözmeye çalışmak. İzin sunucudan gelir; ya sunucunun ayarı değişmeli ya da istek kendi sunucun üzerinden geçmeli.
Küçük Bir Deneme
★☆☆ https://jsonplaceholder.typicode.com/users/1 adresinden kullanıcıyı çek ve adını, e-postasını ve yaşadığı şehri (address.city) konsola yazdır.
★★☆ Çıktıyı tahmin et, sonra dene. Hangi satırlar, hangi sırayla yazılır?
try {
const response = await fetch("https://jsonplaceholder.typicode.com/nope");
console.log("A", response.ok);
const data = await response.json();
console.log("B", data);
} catch (error) {
console.log("C", error.name);
}
★★★ Sayfa örneğine bir "Cancel" butonu ekle. Yeni bir istek başlarken bir AbortController oluştur, iptal butonu onun abort()'unu çağırsın ve iptal edildiğinde ekranda "Request cancelled" yazsın. İpucu: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]) ile iki sinyali birleştirebilirsin.
Bu yazıda sunucudan gelen veriyi ekrana basarken DOM'a birkaç satırlık dokunuşlar yaptık. Liste uzadıkça, kartlar karmaşıklaştıkça bu işin daha düzenli ve daha hızlı yolları gerekiyor. Bir sonraki yazıda DOM ağacında gezinmeyi, dataset'i, template etiketini ve sayfayı yormadan toplu güncelleme yapmayı göreceğiz.