> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# التخزين في المتصفح وتطبيقات PWA: الحدود والعمل دون إنترنت
- URL: https://arabroot.io/articles/التخزين-في-المتصفح-وتطبيقات-الويب-التقدمية-pwa/
- Published: 2026-10-30T18:42:41.000Z
- Updated: 2026-10-30T19:46:18.000Z
- Description: قسنا في Chrome حدود الكوكيز وlocalStorage وIndexedDB وCache API حتى رفض المتصفح، ثم شغلنا تطبيقاً والسيرفر متوقف ونشرنا إصداراً جديداً لنرى متى يصل.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, تطوير الواجهات والجوال Frontend & Mobile

لنفرض أن متجر القهوة والتمور بدأ يجهز الطلبات من مستودع صغير، والموظف يستخدم صفحة ويب على جوال ليرى قائمة الطلبات ويعلم كل طلب بأنه جهز. والمشكلة أن الشبكة داخل المستودع ضعيفة، فكلما دخل الموظف إلى آخر الممرات انقطع الاتصال، وظهرت له صفحة الديناصور المعروفة في Chrome، وضاع ما كان يعمله. وفي نفس الأسبوع لاحظ مطور في الفريق أن كل طلب من الموقع إلى السيرفر، حتى طلب صورة صغيرة، يحمل ترويسة Cookie بعدة كيلوبايتات، لأن أحدهم قبل سنة قرر أن يحفظ السلة كاملة في ملف تعريف ارتباط. وثالثة المشكلات أن عميلاً اشتكى من أن سلته اختفت بعد أسبوع من آخر زيارة على جهاز iPhone.

وكل هذه المشكلات أسئلة عن التخزين في المتصفح: أين تحفظ البيانات، وكم تستطيع أن تحفظ، ومن يستطيع قراءتها، ومتى يحذفها المتصفح دون أن يسألك، وكيف تجعل الصفحة تعمل عندما لا توجد شبكة. وهذا المقال من مرحلة [هندسة الواجهات وتطبيقات الويب Frontend](https://arabroot.io/start/#stage-web-frontend)، وقد شرحنا في [مقال الجلسات وملفات تعريف الارتباط وJWT](https://arabroot.io/articles/%D8%A7%D9%84%D8%AC%D9%84%D8%B3%D8%A7%D8%AA-%D9%88%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D8%AA%D8%B9%D8%B1%D9%8A%D9%81-%D8%A7%D9%84%D8%A7%D8%B1%D8%AA%D8%A8%D8%A7%D8%B7-%D9%88-jwt-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) كيف يتذكرك الموقع بعد تسجيل الدخول وأين تحفظ ما يثبت الدخول، فلن نعيد ذلك هنا، وإنما نركز على بقية البيانات: السلة والتفضيلات والكتالوج والملفات التي تحتاجها الصفحة لتعمل دون إنترنت. وسوف نقيس كل حد بأنفسنا في Chrome بدلاً من نقل الأرقام من المقالات.

وسوف نناقش في هذا المقال ما يلي:

- خريطة أنواع التخزين في المتصفح، ولماذا يوجد أكثر من نوع.
- ملفات تعريف الارتباط Cookies وlocalStorage وIndexedDB وCache API: كيف يعمل كل نوع، وحدوده، ومتى تختاره.
- الحصة Quota والإخلاء Eviction: كم مساحة يعطيها المتصفح لموقعك، ومتى يحذف بياناته.
- العامل في الخلفية Service Worker وتطبيقات الويب التقدمية PWA والعمل دون إنترنت.
- التجربة العملية: حدود كل نوع مقيسة في Chrome، ثم تطبيق يعمل والسيرفر متوقف، ثم نشر إصدار جديد ومتى يصل إلى المستخدم.
- ما لا تضعه في التخزين المحلي، والأخطاء الشائعة، وجدول قرار.

## أين يحفظ المتصفح البيانات؟ خريطة التخزين Storage

في البداية كان في المتصفح نوع واحد فقط من التخزين، وهو ملفات تعريف الارتباط، وصممت لغرض واحد: أن يتذكر السيرفر المتصفح بين طلب وآخر. ثم احتاجت التطبيقات أن تحفظ أشياء لا تخص السيرفر، مثل تفضيلات العرض أو مسودة رسالة، فجاء التخزين المحلي Web Storage بنوعيه `localStorage` و`sessionStorage`. ثم احتاجت تطبيقات البريد والخرائط والمستندات أن تحفظ كميات كبيرة من البيانات المنظمة وتبحث فيها، فجاءت IndexedDB. ثم جاء العمل دون إنترنت، فاحتجنا مكاناً يحفظ ردود HTTP كاملة، وهو Cache API، ووسيطاً يقرر من أين يأتي كل رد، وهو Service Worker.

أي أن كل نوع جاء لحل مشكلة لم يحلها ما قبله، ولذلك فإن السؤال الصحيح ليس «أي نوع أفضل؟» وإنما «ما نوع هذه البيانات؟». والصورة التالية تلخص الخريطة كلها قبل أن ندخل في التفاصيل:

![ست بطاقات: ملفات تعريف الارتباط للجلسة وترسل مع كل طلب وحدها 4096 بايت، وlocalStorage وsessionStorage للتفضيلات الصغيرة متزامنة ونصوص فقط وقرابة 10 MiB، وIndexedDB للبيانات المنظمة الكبيرة غير متزامنة وفيها فهارس، وCache API لردود HTTP الكاملة، وService Worker وسيط يعترض الطلبات للعمل دون إنترنت، وكلها مربوطة بالمصدر وتمسح معاً](https://arabroot.io/content/images/2026/10/browser-storage-pwa-01-storage-map.webp)

كل نوع جاء لمشكلة، والسؤال الصحيح هو نوع البيانات وليس أفضل نوع

ويوجد أمر مشترك بين كل هذه الأنواع يجب أن تتذكره من البداية، وهو أنها مربوطة بالمصدر Origin، أي البروتوكول والنطاق والمنفذ معاً، كما شرحنا في [مقال CORS وسياسة المصدر الواحد](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-cors-%D9%88%D8%B3%D9%8A%D8%A7%D8%B3%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1-%D8%A7%D9%84%D9%88%D8%A7%D8%AD%D8%AF/). فالبيانات التي يحفظها `https://shop.example.com` لا يراها `https://example.com`، ولا يراها نفس الموقع على `http`. وهذا يعني أن نقل الموقع إلى نطاق جديد يعني أن كل ما حفظه المستخدمون في المتصفح لن يظهر في الموقع الجديد.

## ملفات تعريف الارتباط Cookies: صغيرة وترسل مع كل طلب

ملف تعريف الارتباط Cookie نص صغير يحفظه المتصفح لموقع معين، ويرسله تلقائياً في ترويسة `Cookie` مع كل طلب إلى نفس الموقع. وهذه الميزة هي نفسها العيب، فكل بايت تضعه في ملف تعريف ارتباط يسافر مع كل طلب: الصفحة، وملفات CSS، والصور، وطلبات الـ API، حتى لو لم يكن للسيرفر أي حاجة به. والمعيار [RFC 6265](https://www.rfc-editor.org/rfc/rfc6265?ref=arabroot.io#section-6.1) يطلب من المتصفحات أن تدعم على الأقل 4096 بايت لكل ملف، و50 ملفاً لكل نطاق، وسوف ترى في التجربة ما يفعله Chrome فعلاً عند هذه الحدود.

لذلك فإن القاعدة البسيطة أن ملفات تعريف الارتباط لما يحتاجه السيرفر مع كل طلب فقط، وأهمها معرف الجلسة، ومعه الخصائص التي تحميه مثل `HttpOnly` و`Secure` و`SameSite` التي شرحناها في مقال الجلسات. أما السلة كاملة، أو تفضيلات العرض، أو آخر منتجات شاهدها المستخدم، فمكانها ليس هنا، والسلة في قصة المتجر مثال واضح: البيانات التي يحتاجها المتصفح فقط وضعت في المكان الوحيد الذي يرسل كل شيء إلى السيرفر.

ولكثرة الكوكيز ثمن آخر لا يظهر إلا يوماً ما على السيرفر، فكل خادم ويب له حد لحجم الترويسات، وفي Nginx مثلاً القيمة الافتراضية للإعداد [large\_client\_header\_buffers](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fcore%5Fmodule.html?ref=arabroot.io#large%5Fclient%5Fheader%5Fbuffers) هي `4 8k`، أي أن أي ترويسة واحدة أكبر من 8 KB ترفض بالخطأ 400 Bad Request. فإذا كبرت ترويسة Cookie عند بعض المستخدمين فقط، لأنهم زاروا أقساماً كثيرة من الموقع تكتب كل منها ملفاتها، فسوف يرى هؤلاء وحدهم صفحة خطأ لا يستطيع الفريق تكرارها على أجهزته، وهذه من أصعب المشكلات تشخيصاً.

## localStorage و sessionStorage: سهلة ومتزامنة Synchronous

التخزين المحلي Web Storage هو أبسط واجهة تخزين في المتصفح: مفتاح نصي وقيمة نصية، بالدالتين `setItem` و`getItem`، والفرق بين النوعين في العمر فقط. فـ [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage?ref=arabroot.io) تبقى حتى يحذفها الموقع أو المستخدم أو المتصفح، و`sessionStorage` تبقى ما دام التبويب مفتوحاً، وكل تبويب له نسخته الخاصة. ولهذه البساطة سببان لشعبيتها: أنها لا تحتاج أي مكتبة، وأن القيمة متاحة فوراً بدون وعود Promises ولا ردود.

ولكن «فوراً» هنا لها وجه آخر، فالواجهة متزامنة Synchronous، أي أن `setItem` لا ترجع حتى تنتهي الكتابة، وهي تعمل على الخيط الرئيسي Main Thread الذي يعمل عليه كل شيء آخر في الصفحة، كما شرحنا في [مقال حلقة الأحداث](https://arabroot.io/articles/%D8%AD%D9%84%D9%82%D8%A9-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-event-loop-%D9%81%D9%8A-javascript/). والقيمة نص فقط، فإذا أردت أن تحفظ كائناً فعليك بـ `JSON.stringify` عند الحفظ و`JSON.parse` عند القراءة، وكلاهما متزامن أيضاً ويكبر زمنه مع حجم البيانات. لذلك فإن localStorage مناسبة للقيم الصغيرة التي تقرأ مرة عند بداية الصفحة، مثل لغة الواجهة أو الوضع الليلي، وغير مناسبة لكتالوج من آلاف المنتجات. وتذكر أيضاً أن أي كود JavaScript يعمل في صفحتك يستطيع قراءتها، بما فيه أي سكربت حقنه مخترق عبر ثغرة XSS، فلا تضع فيها شيئاً لا تريده أن يصل إلى غيرك.

## IndexedDB: قاعدة بيانات Database داخل المتصفح

[IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB%5FAPI?ref=arabroot.io) قاعدة بيانات حقيقية داخل المتصفح، تحفظ كائنات JavaScript كما هي، بما فيها التواريخ والملفات الثنائية Blobs، في مخازن Object Stores، ولكل مخزن مفتاح أساسي Key Path، ويمكن أن تضيف إليه فهارس Indexes للبحث بحقول أخرى. وكل قراءة أو كتابة تتم داخل معاملة Transaction، فإذا فشلت كتابة واحدة في المعاملة تلغى كلها، وهذا مهم عندما تحفظ طلباً وعناصره معاً. والواجهة غير متزامنة Asynchronous، أي أن العمل الثقيل يتم خارج الخيط الرئيسي، والصفحة لا تتجمد أثناء حفظ آلاف السجلات.

والعيب المعروف في IndexedDB أن واجهتها قديمة الطراز، مبنية على الأحداث `onsuccess` و`onerror` وليس على الوعود، ولذلك يستخدم كثير من المطورين مكتبة صغيرة تلفها بوعود. ويوجد فيها مفهوم الإصدار Version، فعندما تغير شكل المخازن أو تضيف فهرساً عليك أن ترفع رقم الإصدار، وتكتب ما يتغير في الحدث `onupgradeneeded`، وهذا يشبه الترحيل Migration في قواعد بيانات السيرفر. ومن أهم ما يجب أن تعرفه أن ترقية الإصدار تتوقف إذا كان في المتصفح تبويب آخر مفتوح على الإصدار القديم من الموقع، لذلك يجب أن يستمع كودك للحدث `versionchange` ويغلق الاتصال القديم.

## Cache API: تخزين ردود HTTP كاملة

[Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache?ref=arabroot.io) مخزن من نوع خاص، المفتاح فيه طلب HTTP والقيمة رد HTTP كامل بترويساته وجسمه. وصمم أصلاً ليعمل مع Service Worker، فيحفظ فيه ملفات الواجهة ويعيدها عندما لا توجد شبكة، ولكنه متاح أيضاً من الصفحة نفسها. ولاحظ أنه شيء مختلف تماماً عن ذاكرة HTTP المؤقتة التي تحكمها ترويسة `Cache-Control`، فتلك يديرها المتصفح وحده ويحذف منها متى شاء، أما Cache API فأنت تقرر ما يدخلها وما يخرج منها، ولا يحذف منها شيء إلا بكودك أو بإخلاء الموقع كاملاً. ولذلك فإن نسيان حذف الإصدارات القديمة من Cache API خطأ شائع يجعل الموقع يكبر في جهاز المستخدم مع كل تحديث، وسوف ترى في التجربة كيف يحذف كود التفعيل الإصدار القديم.

## الحصة Quota والإخلاء Eviction: متى تختفي بيانات موقعك؟

IndexedDB وCache API تشتركان في حصة واحدة لكل موقع، ويقرر المتصفح حجمها بحسب مساحة القرص. وبحسب صفحة [حدود التخزين ومعايير الإخلاء في MDN](https://developer.mozilla.org/en-US/docs/Web/API/Storage%5FAPI/Storage%5Fquotas%5Fand%5Feviction%5Fcriteria?ref=arabroot.io) يسمح Chrome لكل مصدر بقرابة 60% من حجم القرص الكلي، ويسمح Firefox في الوضع العادي بالأقل من 10% من القرص أو 10 GiB، وتحسب الحصة من حجم القرص الكلي وليس من المساحة الفارغة، حتى لا تكشف للمواقع شيئاً عن جهاز المستخدم. وتستطيع أن تسأل المتصفح عن الحصة والاستخدام بالدالة [navigator.storage.estimate()](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/estimate?ref=arabroot.io)، ولكن كما يقول اسمها، هي تقدير، وسوف نرى في التجربة متى لا تقول الحقيقة.

والأهم من الحصة هو الإخلاء Eviction، أي أن يحذف المتصفح بيانات موقعك دون أن يسأل. فبيانات أي موقع هي افتراضياً في وضع الجهد الأفضل Best-Effort، وعندما تقل مساحة الجهاز يحذف المتصفح بيانات المواقع الأقل استخداماً مؤخراً Least Recently Used، والموقع يحذف كاملاً وليس جزءاً منه. ويستطيع الموقع أن يطلب التخزين الدائم Persistent Storage بالدالة `navigator.storage.persist()`، فلا تحذف بياناته إلا بقرار المستخدم، ويشرح [مقال Chrome عن التخزين الدائم](https://web.dev/articles/persistent-storage?ref=arabroot.io) أن Chrome يمنح هذا الإذن تلقائياً بحسب تفاعل المستخدم مع الموقع، مثل أن يكون مثبتاً أو محفوظاً في المفضلة. وفي Safari قاعدة إضافية شرحها فريق WebKit في [إعلان منع التتبع](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/?ref=arabroot.io)، وتذكرها MDN أيضاً: إذا لم يتفاعل المستخدم مع الموقع سبعة أيام من استخدام المتصفح، تحذف كل البيانات التي كتبها JavaScript، وهذا يفسر السلة التي اختفت عند عميل iPhone في القصة.

![ثلاث بطاقات: وضع الجهد الأفضل حيث يحذف المتصفح بيانات الموقع الأقل استخداماً كاملة عند امتلاء الجهاز، والتخزين الدائم الذي يطلب بـ persist ورجع false في Chrome بدون واجهة، وSafari الذي يحذف بيانات JavaScript بعد سبعة أيام بدون تفاعل، وتحتها قائمة قبل مسح بيانات الموقع فيها cookie وlocalStorage وIndexedDB وCache واستخدام 1253 بايت، وبعده كلها صفر](https://arabroot.io/content/images/2026/10/browser-storage-pwa-05-eviction.webp)

التخزين في المتصفح نسخة مؤقتة، والسيرفر هو المصدر الحقيقي للبيانات

والنتيجة العملية لكل هذا قاعدة واحدة: التخزين في المتصفح نسخة مؤقتة Cache، وليس قاعدة البيانات الأصلية. فأي شيء لا تريد أن يضيع، مثل الطلبات التي لم ترسل بعد، يجب أن يرسل إلى السيرفر في أقرب فرصة، والتطبيق يجب أن يعمل بشكل صحيح إذا وجد التخزين فارغاً.

## العامل في الخلفية Service Worker: وسيط بين الصفحة والشبكة

[Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service%5FWorker%5FAPI?ref=arabroot.io) ملف JavaScript يسجله الموقع، ويعمل في خيط منفصل عن الصفحة، ولا يرى الـ DOM. وأهم ما يفعله أنه يعترض كل طلب تخرجه الصفحات التي يتحكم فيها، في الحدث `fetch`، ويقرر كيف يرد: من Cache API، أو من الشبكة، أو من خليط بينهما. ولأنه يستطيع أن يرد على أي طلب بأي محتوى، فلا يعمل إلا على HTTPS أو على `localhost`، وإلا استطاع أي شخص في منتصف الشبكة أن يزرع عاملاً يتحكم في الموقع.

وللعامل دورة حياة يجب أن تفهمها قبل أن تكتب سطراً، ويشرحها فريق Chrome بالتفصيل في [مقال دورة حياة Service Worker](https://web.dev/articles/service-worker-lifecycle?ref=arabroot.io). فعند أول تسجيل يمر العامل بمرحلة التثبيت Install، وفيها يحمل عادة ملفات الواجهة إلى Cache API، ثم مرحلة التفعيل Activate، وفيها يحذف ما تركته الإصدارات القديمة. ولكنه لا يتحكم في الصفحة التي سجلته إلا بعد إعادة تحميلها، وعندما تنشر إصداراً جديداً من العامل يثبت الجديد ثم ينتظر Waiting حتى تغلق كل التبويبات التي يتحكم فيها القديم. وهذا الانتظار مقصود، لأن الصفحة المفتوحة حملت ملفات الإصدار القديم، ولو تحكم فيها العامل الجديد فجأة لاختلطت ملفات إصدارين في نفس الصفحة. وسوف ترى هذا الانتظار بالأرقام في تجربة التحديث.

أما طريقة الرد على كل طلب فتسمى الاستراتيجية Strategy، ويجمع فريق Chrome أشهرها في [The Offline Cookbook](https://web.dev/articles/offline-cookbook?ref=arabroot.io). وأهمها ثلاث:

- **الـ Cache أولاً Cache First:** ابحث في الـ Cache، وإذا لم تجد فاطلب من الشبكة. وهي مناسبة لملفات الواجهة التي تتغير فقط مع نشر إصدار جديد، وأسماؤها تحمل الإصدار.
- **الشبكة أولاً Network First:** اطلب من الشبكة، واحفظ نسخة، وإذا فشلت الشبكة فرد بالنسخة المحفوظة. وهي مناسبة للبيانات التي تريدها حديثة، مع قبول نسخة قديمة عند الانقطاع.
- **القديم ثم التحديث Stale While Revalidate:** رد فوراً بالنسخة المحفوظة، واطلب نسخة جديدة في الخلفية للمرة القادمة. وهي مناسبة لما يقبل أن يتأخر قليلاً، مثل صور المنتجات أو قائمة التصنيفات.

وكتابة هذه الاستراتيجيات يدوياً سهلة لصفحة صغيرة كما في التجربة، ولكن في التطبيقات الكبيرة تستخدم أغلب الفرق مكتبة [Workbox](https://developer.chrome.com/docs/workbox?ref=arabroot.io) من Google، لأنها تتعامل مع تفاصيل مثل أسماء الملفات بالإصدار وحدود حجم الـ Cache وانتهاء صلاحية العناصر.

## تطبيقات الويب التقدمية PWA: البيان Manifest والتثبيت Install

تطبيق الويب التقدمي Progressive Web App ليس تقنية واحدة، وإنما موقع عادي يجمع ثلاثة أشياء: يعمل على HTTPS، وفيه Service Worker يجعله يعمل دون إنترنت أو مع شبكة ضعيفة، وفيه ملف بيان [Web App Manifest](https://developer.mozilla.org/en-US/docs/Web/Progressive%5Fweb%5Fapps/Manifest?ref=arabroot.io) يصف التطبيق للمتصفح: اسمه وأيقوناته ولونه ورابط البداية وطريقة العرض. وعندما تتوفر [شروط التثبيت](https://web.dev/articles/install-criteria?ref=arabroot.io) يعرض المتصفح على المستخدم أن يضيف التطبيق إلى الشاشة الرئيسية، فيفتح بعدها في نافذة خاصة به بدون شريط العنوان، ويبدو للمستخدم كتطبيق عادي.

ولا تنس في التطبيق العربي أن البيان نفسه يحمل اللغة والاتجاه، بالحقلين `lang` و`dir`، حتى يعرض النظام اسم التطبيق بالاتجاه الصحيح، وقد شرحنا مشكلات الاتجاه في [مقال التعريب i18n](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B9%D8%B1%D9%8A%D8%A8-i18n-%D9%88%D8%A7%D9%84%D9%86%D8%B5-%D8%AB%D9%86%D8%A7%D8%A6%D9%8A-%D8%A7%D9%84%D8%A7%D8%AA%D8%AC%D8%A7%D9%87-bidi-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/). وفي حالة المستودع في القصة، فإن PWA هي الحل المناسب تماماً: صفحة واحدة يفتحها الموظف من أيقونة على الشاشة، وتعمل عندما تنقطع الشبكة، وتحفظ ما عمله في IndexedDB حتى تعود الشبكة فترسله، دون أن تحتاج تطبيقاً منفصلاً لكل نظام تشغيل.

## الكتابة دون إنترنت: صندوق الصادر Outbox

قراءة البيانات دون إنترنت هي النصف السهل، أما النصف الصعب فهو ما يكتبه المستخدم أثناء الانقطاع، مثل الموظف في المستودع الذي يعلم ثلاثة طلبات بأنها جهزت وهو في آخر الممرات. والنمط المعروف لهذا يسمى صندوق الصادر Outbox: كل عملية يقوم بها المستخدم تحفظ أولاً في مخزن خاص في IndexedDB مع معرف فريد ووقت، ثم تعرضها الواجهة كأنها تمت مع علامة «بانتظار الإرسال»، وعندما تعود الشبكة، أي عند الحدث `online` أو عند فتح التطبيق، يرسل الكود العمليات واحدة بعد الأخرى ويحذف كل عملية من الصندوق بعد أن يؤكد السيرفر استلامها.

وفي هذا النمط تفصيلان يحددان نجاحه. الأول أن الإرسال قد يتكرر، فقد تصل العملية إلى السيرفر ثم ينقطع الاتصال قبل أن يصل الرد، فيعيد الكود إرسالها، لذلك يجب أن يرسل المعرف الفريد مع كل عملية، وأن يتجاهل السيرفر العملية التي استلمها من قبل، وهذا ما يسمى الطلب المتكرر الآمن Idempotent Request. والثاني أن العالم تغير أثناء الانقطاع، فقد يكون زميل آخر جهز نفس الطلب، أو ألغاه العميل، لذلك يجب أن يرد السيرفر برفض واضح عندما لا تصلح العملية، وأن تعرض الواجهة هذا الرفض للمستخدم بدلاً من أن تحذفه بصمت. وتذكر أن صندوق الصادر نفسه في IndexedDB، فهو معرض للإخلاء مثل أي بيانات أخرى، لذلك أرسل محتواه بأسرع ما يمكن ولا تتركه أياماً.

## التجربة العملية Lab: نقيس الحدود ثم نقطع الشبكة

الآن نقيس كل ما سبق في Chrome 156 بدون واجهة. التجربة جزءان: صفحة مختبر فيها دوال تختبر كل نوع من التخزين حتى يرفض المتصفح، وتطبيق صغير للمتجر فيه Service Worker وبيان. ويشغل السكربت `measure.mjs` السيرفر بنفسه كعملية فرعية، حتى يستطيع أن يوقفه ليمثل انقطاع الشبكة، ثم يعيد تشغيله بإصدار جديد من التطبيق.

### الملفات والتشغيل Setup

السيرفر `server.mjs` يقدم ملفات المجلد `public`، ومعها مساران: `/echo-cookie` يرجع حجم ترويسة Cookie التي وصلته، و`/api/products` يرجع قائمة المنتجات مع وقت الإرسال. ويقدم أيضاً الملفين `/app/sw.js` و`/app/app.js` من قالبين، ويضع فيهما رقم الإصدار من متغير البيئة `APP_VERSION`، حتى نستطيع أن «ننشر» إصداراً جديداً بإعادة تشغيل السيرفر فقط. والعامل في الخلفية كما يلي:

```javascript
// Service worker of the store app. The server replaces __VERSION__ when it deploys a new version.
const VERSION = "__VERSION__";
const SHELL = ["/app/", "/app/app.js", "/app/style.css", "/app/manifest.webmanifest", "/app/icon-192.png"];

// install: download the app shell into a cache named after this version
self.addEventListener("install", (e) => {
  e.waitUntil(caches.open(`shell-${VERSION}`).then((c) => c.addAll(SHELL)));
});

// activate: delete the shells of older versions
self.addEventListener("activate", (e) => {
  e.waitUntil(caches.keys().then((keys) => Promise.all(
    keys.filter((k) => k.startsWith("shell-") && k !== `shell-${VERSION}`).map((k) => caches.delete(k)))));
});

// the page asks the waiting worker to take over now (the "update available" button)
self.addEventListener("message", (e) => { if (e.data === "skipWaiting") self.skipWaiting(); });

self.addEventListener("fetch", (e) => {
  const url = new URL(e.request.url);
  if (url.pathname.startsWith("/api/")) {
    // data: network first, and keep a copy; when the network fails, answer from the copy
    e.respondWith(fetch(e.request)
      .then((r) => { const copy = r.clone(); caches.open("data").then((c) => c.put(e.request, copy)); return r; })
      .catch(() => caches.match(e.request)));
    return;
  }
  // app shell: cache first, network only for what is not cached
  e.respondWith(caches.match(e.request).then((r) => r || fetch(e.request)));
});
```

وفي الكود أعلاه لاحظ التالي:

- اسم الـ Cache يحمل الإصدار `shell-v1`، فالإصدار الجديد يحمل ملفاته في Cache جديد دون أن يلمس ملفات الإصدار الذي يعمل الآن.
- `e.waitUntil` يقول للمتصفح ألا يعتبر التثبيت ناجحاً حتى تحمل كل الملفات، فإذا فشل ملف واحد يفشل التثبيت كله ويبقى العامل القديم، وهذا أفضل من تطبيق نصف ملفاته مفقودة.
- ملفات الواجهة بالـ Cache أولاً، وبيانات `/api/` بالشبكة أولاً مع نسخة احتياطية في Cache اسمه `data`.
- الحدث `message` يستقبل طلب الصفحة بتفعيل العامل الجديد فوراً بالدالة [skipWaiting](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting?ref=arabroot.io)، وتستدعيه الصفحة عندما يضغط المستخدم زر «يوجد تحديث».

أما البيان فيحمل الاسم العربي والاتجاه وأيقونتين بمقاسي 192 و512:

```json
{
  "name": "متجر القهوة والتمور",
  "short_name": "القهوة والتمور",
  "lang": "ar",
  "dir": "rtl",
  "start_url": "/app/",
  "scope": "/app/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2c001e",
  "icons": [
    { "src": "/app/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/app/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}
```

والتشغيل أمر واحد، فالسكربت يشغل السيرفر على المنفذ 19396 وChrome عبر المساعد `scripts/lab/browser.mjs`:

```bash
node measure.mjs
```

### الخطوة الأولى: حدود ملفات تعريف الارتباط Cookies

الدالة `cookieTests` تبحث بالتنصيف Binary Search عن أطول قيمة يقبلها المتصفح لملف اسمه `big`، ثم تكتب 300 ملف صغير، كل منها 20 حرفاً، وتسأل السيرفر كم وصله. والمخرج سوف يكون كما يلي:

```text
== 1) cookies
{ "maxValueLength": 4093, "maxNamePlusValue": 4096, "cookiesKeptOf300": 176, "oldestKept": false, "requestCarries": { "cookieHeaderBytes": 4750, "cookies": 176 }, "afterCleanup": { "cookieHeaderBytes": 0, "cookies": 0 } }
```

وفي المخرج أعلاه لاحظ التالي:

- أطول قيمة قبلها Chrome هي 4093 حرفاً، ومع الاسم `big` المكون من ثلاثة حروف يصبح المجموع 4096 بالضبط، أي الحد الذي في المعيار. والأهم أن الملف الأطول من ذلك لم يرم أي خطأ، وإنما تجاهله المتصفح بصمت، فإذا كان كودك يكتب ملفاً يكبر مع الوقت، مثل السلة، فسوف يتوقف عن الحفظ يوماً ما دون أن يخبرك أحد.
- من 300 ملف بقي 176 فقط، والملف الأول `c0` لم يعد موجوداً، أي أن المتصفح حذف الأقدم ليفسح للجديد، وهذا أيضاً بدون أي خطأ.
- بعد ذلك حمل كل طلب إلى السيرفر ترويسة Cookie حجمها 4,750 بايت. وتذكر أن هذا يتكرر مع كل طلب، فصفحة فيها 40 ملفاً بين صور وسكربتات ترسل قرابة 190 KB من الترويسات في الاتجاه البطيء، أي من الجوال إلى السيرفر، قبل أن يصل أي رد.

### الخطوة الثانية: localStorage حتى الرفض

الدالة `localStorageTests` تملأ localStorage بقطع من 262,144 حرفاً حتى يرفض المتصفح، ثم تكمل بقطع أصغر، مرة بالحرف اللاتيني `a` ومرة بالحرف العربي `ق`، ثم تقيس زمن كتابة وقراءة قيمة بحجم 1 MiB تقريباً:

```text
== 2) localStorage
{ "latin": { "error": "QuotaExceededError", "characters": 5111895, "mib_if_2_bytes_per_char": 9.75 }, "arabic": { "error": "QuotaExceededError", "characters": 5111895, "mib_if_2_bytes_per_char": 9.75 }, "write1MiB_ms": 7.7, "read1MiB_ms": 0.2, "sameBack": true }
```

هنا يختلف السلوك عن ملفات تعريف الارتباط، فعند الامتلاء رمى المتصفح `QuotaExceededError`، وهذا جيد لأن كودك يستطيع أن يلتقطه بـ `try...catch`. وتوقف المتصفح بعد 5,111,895 حرفاً، والسكربت لا يبحث عن الحافة بدقة الحرف الواحد، فالحد الحقيقي أعلى بقليل، ويقارب 10 MiB إذا حسبنا كل حرف ببايتين كما تخزن JavaScript النصوص، وهذا يتفق مع ما تذكره MDN عن حد Web Storage. والملاحظة المهمة للمواقع العربية أن العدد تطابق تماماً للنص اللاتيني والعربي، أي أن Chrome يحسب الحد بعدد الحروف وليس بعدد بايتات UTF-8، فالحرف العربي لا يأخذ ضعف المساحة هنا كما قد تتوقع. أما الكتابة المتزامنة لقيمة بحجم 1 MiB فأخذت 7.7 ms على الخيط الرئيسي، وهذا في جهاز سريع، ومعه زمن `JSON.stringify` لو كانت القيمة كائناً، فإذا كان كودك يحفظ السلة كاملة مع كل تغيير فأنت تدفع هذا الزمن مع كل نقرة.

### الخطوة الثالثة: عشرة آلاف منتج في IndexedDB

الدالة `indexedDbTests` تنشئ مخزناً للمنتجات بفهرس على النوع `byKind`، وتكتب عشرة آلاف منتج في معاملة واحدة، ثم تعد التمور بالفهرس، وتسأل `estimate()` قبل ذلك وبعده:

```text
== 3) IndexedDB, 10,000 products
{ "write10000_ms": 1326, "indexCount_ms": 20.7, "dates": 2000, "usageBefore": 0, "usageAfter": 4487226, "quota": 10741905466, "details": { "indexedDB": 4487226 } }

== persist() in headless Chrome
{ "granted": false, "persisted": false }
```

وفي المخرج أعلاه لاحظ التالي:

- الكتابة أخذت 1326 ms في هذا التشغيل، وفي أربعة تشغيلات كانت بين 1326 و3746 ms كما في الملف `idb-runs.txt`، ولكن هذا الزمن لم يحجز الخيط الرئيسي، لأن الواجهة غير متزامنة. والعد بالفهرس أعطى 2000 تمر في 20.7 ms، دون أن يمر على العشرة آلاف.
- عشرة آلاف منتج أخذت 4,487,226 بايت، أي قرابة 450 بايت للمنتج، وهذه مساحة لا تحلم بها في localStorage بنفس السهولة.
- الحصة التي أعلنها المتصفح 10,741,905,466 بايت، أي قرابة 10 GB، وليست 60% من قرص الجهاز كما تقول MDN عن Chrome، والسبب على الأغلب أن Chrome بدون واجهة يعمل بملف شخصي Profile مؤقت. وهذا تذكير بأن الحصة رقم يقرره المتصفح بحسب ظروفه، فلا تكتب كوداً يفترض رقماً ثابتاً.
- `persist()` رجعت `false`، فـ Chrome لا يعطي التخزين الدائم لموقع لم يتفاعل معه المستخدم، وهذا متوقع في ملف شخصي جديد لم يزر الموقع قبل ذلك.

### الخطوة الرابعة: الحصة Quota عند الامتلاء

لكي نرى ما يحدث عند امتلاء الحصة دون أن نملأ 10 GB من القرص، حددنا حصة الموقع بـ 50 MiB بالأمر `Storage.overrideQuotaForOrigin` من [بروتوكول DevTools](https://chromedevtools.github.io/devtools-protocol/tot/Storage/?ref=arabroot.io)، وهذا يمثل جوالاً قرصه ممتلئ تقريباً، ثم كتبنا ملفات بحجم 1 MiB في IndexedDB حتى الرفض، ثم حذفناها وكررنا نفس الشيء في Cache API:

```text
== 4) quota overridden to 50 MiB, then filled
{ "idbMiB": 49, "idbError": "QuotaExceededError", "usageAfterIdb": 51409913, "openWhenFull": "ok", "cacheMiB": 49, "cacheError": "QuotaExceededError", "usageAfterCache": 51420020, "quota": 10788838260 }
```

توقفت IndexedDB بعد 49 ملفاً، وCache API بعد 49 ملفاً أيضاً، وكل منهما رمى `QuotaExceededError`، أي أن النوعين يشتركان في نفس الحصة، والاستخدام عند التوقف كان 51,409,913 بايت. ولاحظ أن فتح Cache جديد نجح والحصة ممتلئة، فالرفض يأتي عند الكتابة وليس عند الفتح. والملاحظة الأغرب أن `estimate()` ما زالت تقول إن الحصة 10.7 GB، مع أن المتصفح رفض الكتابة عند 50 MiB فعلاً. أي أن الكود الذي يقرر «عندي مساحة كافية» بناءً على `estimate()` وحدها قد يخطئ، والطريقة الصحيحة أن تلتقط `QuotaExceededError` عند كل كتابة مهمة وتتصرف، مثلاً بحذف أقدم البيانات المحفوظة.

![بطاقات بالأرقام المقيسة في Chrome 156: ملف تعريف ارتباط واحد 4096 بايت للاسم والقيمة، ومن 300 ملف بقي 176، وكل طلب يحمل بعدها 4750 بايت، وlocalStorage توقفت عند 5,111,895 حرفاً بنفس العدد للعربي واللاتيني، وIndexedDB كتبت عشرة آلاف منتج بين 1326 و3746 ms في أربعة تشغيلات، وحصة 50 MiB توقفت عند 49 MiB، وestimate قالت 10.7 GB](https://arabroot.io/content/images/2026/10/browser-storage-pwa-02-limits.webp)

كل حد في هذه الصورة قسناه بأنفسنا، والحدود تختلف بين المتصفحات

### الخطوة الخامسة: مسح بيانات الموقع Clear Site Data

نحفظ الآن قيمة في كل نوع: ملف تعريف ارتباط، ومفتاحاً في localStorage، وقاعدة IndexedDB، وCache، ثم نمسح بيانات الموقع بالأمر `Storage.clearDataForOrigin`، وهو نفس ما يحدث عندما يمسح المستخدم بيانات الموقع من الإعدادات، أو عندما يخليه المتصفح:

```text
== 5a) after seeding cookie, localStorage, IndexedDB, Cache
{ "cookies": 1, "localStorageKeys": 1, "idbDatabases": [ "shop2" ], "caches": [ "shell-v1" ], "usage": 1253, "persisted": false }

== 5b) after Storage.clearDataForOrigin(all)
{ "cookies": 0, "localStorageKeys": 0, "idbDatabases": [], "caches": [], "usage": 0, "persisted": false }
```

كل شيء اختفى معاً. وهذا مهم لأن بعض المطورين يظنون أن نوعاً من التخزين «أكثر ثباتاً» من غيره، فيضعون نسخة احتياطية من IndexedDB في localStorage مثلاً، وهذا لا يفيد شيئاً، فالمسح والإخلاء يعملان على الموقع كاملاً. ولاحظ أن نفس الأداة متاحة لك من السيرفر، فالترويسة [Clear-Site-Data](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data?ref=arabroot.io) في رد صفحة تسجيل الخروج تطلب من المتصفح أن يمسح بيانات الموقع، وهذا أنظف من أن تحاول حذف كل مفتاح بيدك.

### الخطوة السادسة: العمل دون إنترنت Offline

نبدأ بسؤال Chrome هل الصفحة قابلة للتثبيت كتطبيق، بالأمر `Page.getInstallabilityErrors`، مرة لصفحة المختبر التي ليس فيها بيان، ومرة للتطبيق، ثم نزور التطبيق مرتين:

```text
== 6a) installability of / (no manifest)
{ "installabilityErrors": [ { "errorId": "no-manifest", "errorArguments": [] } ] }

== 6b) first visit to /app/
{ "version": "v1", "controlled": false, "servedAt": "2026-10-30T19:24:55.852Z", "sw": "not yet" }

== 6c) installability of /app/
{ "installabilityErrors": [] }

== 6d) second visit
{ "version": "v1", "controlled": true, "servedAt": "2026-10-30T19:24:57.535Z", "caches": [ "shell-v1", "data" ] }
```

صفحة المختبر رفضت بالسبب `no-manifest`، والتطبيق لم يظهر أي سبب، أي أنه قابل للتثبيت. وفي الزيارة الأولى كان `controlled` بالقيمة `false`، فالعامل سجل نفسه وثبت، ولكنه لا يتحكم في الصفحة التي سجلته، وفي الزيارة الثانية صار يتحكم، وظهر في Cache API المخزنان `shell-v1` و`data`. والآن يوقف السكربت السيرفر تماماً، ويعيد تحميل التطبيق، ثم يحاول فتح صفحة المختبر:

```text
-- server stopped --

== 6e) /app/ with the server down
{ "version": "v1", "controlled": true, "servedAt": "2026-10-30T19:24:57.535Z", "list": [ "قهوة عربية", "تمر سكري", "هيل", "زعفران" ] }

== 6f) / (not cached) with the server down
{ "errorText": "net::ERR_CONNECTION_REFUSED" }
```

فتح التطبيق كاملاً والسيرفر متوقف، والقائمة ظهرت بأسماء المنتجات الأربعة، ولاحظ أن وقت `servedAt` هو نفسه وقت الزيارة الثانية بالضبط، أي أن البيانات جاءت من النسخة التي حفظتها استراتيجية الشبكة أولاً. ولهذا يعرض التطبيق للمستخدم وقت آخر تحديث، حتى يعرف أنه يرى نسخة محفوظة وليس بيانات حية. أما صفحة المختبر، التي لا يتحكم فيها العامل، فأعطت خطأ `net::ERR_CONNECTION_REFUSED`. وهذا هو حل مشكلة المستودع في القصة: الموظف يفتح التطبيق في آخر الممرات، فيعمل من الـ Cache، ويرى آخر قائمة طلبات وصلته.

![الصفحة تطلب app وapi products، والعامل sw.js يعترض كل طلب فيرد على ملفات الواجهة من الـ Cache أولاً وعلى البيانات من الشبكة أولاً ثم من النسخة المحفوظة، وعلى اليسار Cache API فيها shell-v1 وdata والسيرفر لما ليس في الـ Cache، وتحتها ثلاث نتائج: الزيارة الأولى controlled false، والسيرفر متوقف فتح التطبيق كاملاً، وصفحة ليست في الـ Cache أعطت ERR_CONNECTION_REFUSED](https://arabroot.io/content/images/2026/10/browser-storage-pwa-03-service-worker.webp)

العامل في الخلفية يرد من الـ Cache عندما لا يجد السيرفر

### مشكلتان واجهناهما أثناء التجربة Lab

المشكلة الأولى ظهرت في أول تشغيل، فقد فشل `caches.open` بالخطأ `UnknownError: Failed to execute 'open' on 'CacheStorage': Unexpected internal error`، حتى قبل أن نكتب أي شيء. فكتبنا سكربتاً صغيراً يفتح Cache في صفحة نظيفة، وفشل أيضاً، مع أو بدون تحديد الحصة. والسبب أن المساعد يضع الملف الشخصي المؤقت لـ Chrome في مجلد `TEMP`، وكنا قد غيرنا `TEMP` إلى مجلد عمل مساره طويل جداً، والـ Cache API يحفظ ملفاته في عدة مستويات من المجلدات داخل الملف الشخصي، فالأرجح أن المسار الكامل تجاوز حد طول المسار في Windows. وعندما أعدنا `TEMP` إلى مساره العادي عمل كل شيء. والدرس أن `UnknownError` في واجهات التخزين قد يكون سببه في الجهاز وليس في كودك، مثل قرص ممتلئ أو مسار طويل أو ملف شخصي تالف، لذلك يجب أن يتعامل كودك مع فشل التخزين كاحتمال عادي، فيعمل التطبيق بدونه ولا يتوقف.

والمشكلة الثانية في الخطوة الرابعة، فقد توقعنا أن تظهر `estimate()` الحصة الجديدة بعد `Storage.overrideQuotaForOrigin`، فلم تتغير، وظننا أن التحديد لم يعمل، حتى كتبنا البيانات فعلاً ورفضها المتصفح عند 49 MiB. أي أن الحصة الفعلية والحصة المعلنة اختلفتا، وهذا ما جعلنا نكتب في الخطوة الرابعة أن تعتمد على الخطأ عند الكتابة وليس على التقدير.

### ماذا يحدث إذا نشرت إصداراً جديداً Update؟

هذا هو السؤال الذي يفاجئ أغلب الفرق بعد أول PWA. يعيد السكربت تشغيل السيرفر بـ `APP_VERSION=v2`، أي أن `sw.js` و`app.js` تغيرا، ثم يفتح التطبيق ويقرأ حالة العامل بعد صفر وثانية وثلاث ثوان، ثم يعيد التحميل، ثم يضغط زر التحديث:

```text
-- server restarted with app v2 --

== 6g) /app/ after deploying v2, +0 ms
{ "page": "v1", "installing": null, "waiting": null, "active": "activated", "updateButton": false, "caches": [ "shell-v1", "data" ] }

== 6g) /app/ after deploying v2, +1000 ms
{ "page": "v1", "installing": null, "waiting": null, "active": "activated", "updateButton": false, "caches": [ "shell-v1", "data" ] }

== 6g) /app/ after deploying v2, +3000 ms
{ "page": "v1", "installing": null, "waiting": "installed", "active": "activated", "updateButton": true, "caches": [ "shell-v1", "data", "shell-v2" ] }

== 6h) reload (the same tab stays open)
{ "page": "v1", "installing": null, "waiting": "installed", "active": "activated", "updateButton": true, "caches": [ "shell-v1", "data", "shell-v2" ] }

== 6i) after clicking the update button, before reload
{ "page": "v1", "installing": null, "waiting": null, "active": "activated", "updateButton": true, "caches": [ "data", "shell-v2" ] }

== 6j) after reload
{ "page": "v2", "installing": null, "waiting": null, "active": "activated", "updateButton": false, "caches": [ "data", "shell-v2" ] }
```

وفي المخرج أعلاه لاحظ التالي:

- بعد النشر مباشرة جاءت الصفحة `v1`، لأن العامل القديم رد من الـ Cache أولاً، والسيرفر الجديد لم يسأله أحد عن الصفحة.
- في الخلفية فحص المتصفح `sw.js` ووجده مختلفاً، فثبت العامل الجديد وحمل ملفاته في `shell-v2`، وبعد ثلاث ثوان صار في حالة الانتظار `installed`، وظهر زر التحديث.
- إعادة تحميل الصفحة لم تغير شيئاً، فالصفحة ما زالت `v1`، لأن التبويب نفسه بقي مفتوحاً، والعامل الجديد ينتظر حتى تغلق كل التبويبات التي يتحكم فيها القديم. ولو كان التطبيق مثبتاً ويفتحه المستخدم من الأيقونة ولا يغلقه، فقد يبقى على الإصدار القديم أياماً.
- عند الضغط على الزر استدعى العامل الجديد `skipWaiting()`، فتفعل، وحذف كود التفعيل `shell-v1`، ولكن الصفحة المفتوحة ما زالت تعرض `v1` حتى أعيد تحميلها، فصارت `v2`.

![خمس مراحل للتحديث: بعد النشر بصفر وثانية الصفحة v1 ولا يوجد عامل منتظر، بعد ثلاث ثوان العامل الجديد installed وأضيف shell-v2، بعد إعادة التحميل ما زالت v1، بعد زر التحديث فعل skipWaiting العامل الجديد وحذف shell-v1، وبعد إعادة التحميل صارت الصفحة v2](https://arabroot.io/content/images/2026/10/browser-storage-pwa-04-update.webp)

إعادة التحميل وحدها لا تكفي، والعامل الجديد ينتظر إغلاق التبويب أو زر التحديث

والحل المعروف هو ما فعلناه في التجربة: عندما يظهر عامل منتظر، اعرض للمستخدم رسالة «يوجد تحديث»، وعند الضغط أرسل `skipWaiting` ثم أعد تحميل الصفحة عند الحدث `controllerchange`. ولا ينصح باستدعاء `skipWaiting()` تلقائياً عند التثبيت في كل الحالات، لأن الصفحة المفتوحة تكون قد حملت جزءاً من ملفات الإصدار القديم، فإذا طلبت ملفاً جديداً بعد التفعيل فقد تحصل على ملف من إصدار مختلف عن بقية الصفحة. والأهم من كل هذا أن يتعامل السيرفر مع العملاء القدامى، فـ API الإصدار الثاني يجب أن يقبل طلبات الإصدار الأول لفترة، لأن بعض المستخدمين سيبقون عليه، وهذه نفس فكرة التوافق مع الإصدارات السابقة التي يحتاجها أي تطبيق جوال.

## الأمان Security: ما لا تضعه في التخزين المحلي

كل ما في localStorage وIndexedDB وCache API يستطيع أن يقرأه أي كود JavaScript يعمل في صفحتك، ويبقى على الجهاز بعد إغلاق المتصفح، ويستطيع أي شخص يستخدم نفس الجهاز أن يراه من أدوات المطور. لذلك فإن القواعد التالية لا تتغير مهما كان نوع التخزين:

- **ما يثبت الدخول:** مكانه ملف تعريف ارتباط `HttpOnly` وليس localStorage، والسبب أن ثغرة XSS واحدة تقرأ localStorage كاملة، أما `HttpOnly` فلا تراه JavaScript أصلاً، وقد شرحنا ذلك بالتفصيل في مقال الجلسات.
- **البيانات الشخصية الحساسة:** مثل أرقام الهوية أو العناوين الكاملة أو بيانات الدفع، لا تحفظها في المتصفح إلا إذا كان التطبيق يحتاجها فعلاً دون إنترنت، وعندها احذفها عند تسجيل الخروج.
- **ردود API الخاصة بمستخدم:** إذا حفظتها في Cache API باستراتيجية الشبكة أولاً كما في التجربة، فهي باقية بعد تسجيل الخروج، والمستخدم التالي على نفس الجهاز سيراها عند انقطاع الشبكة. امسح الـ Cache الخاص بالبيانات عند تسجيل الخروج، أو استخدم ترويسة `Clear-Site-Data`.
- **الأسعار والصلاحيات:** لا تثق بأي قيمة تقرؤها من التخزين المحلي في قرار يخص المال أو الصلاحيات، فالمستخدم يستطيع تعديلها بيده، والسيرفر يتحقق دائماً.

## أخطاء شائعة Common Mistakes

- **حفظ بيانات كبيرة في ملفات تعريف الارتباط.** رأيت في التجربة أن ما زاد عن 4096 بايت يهمل بصمت، وأن كل ملف يرسل مع كل طلب. ضع في الكوكيز معرف الجلسة فقط.
- **استخدام localStorage لكتالوج أو سجل كبير.** الواجهة متزامنة وتحجز الخيط الرئيسي، والحد قرابة 10 MiB. استخدم IndexedDB.
- **عدم التقاط `QuotaExceededError`.** الكتابة تفشل يوماً ما على جهاز ممتلئ، والكود الذي لا يلتقط الخطأ يتوقف في منتصف العملية.
- **اعتبار التخزين المحلي مصدراً للحقيقة.** الإخلاء يحذف الموقع كاملاً، وSafari يحذف بيانات JavaScript بعد سبعة أيام بدون تفاعل. أرسل كل ما لا يجب أن يضيع إلى السيرفر.
- **نسيان حذف الإصدارات القديمة من Cache API.** كل إصدار يضيف نسخة كاملة من الواجهة على جهاز المستخدم. احذف القديم في حدث `activate`.
- **Cache أولاً لبيانات تتغير.** قائمة الأسعار بالـ Cache أولاً تعني أن المستخدم يرى أسعاراً قديمة إلى الأبد. البيانات بالشبكة أولاً أو بالقديم ثم التحديث.
- **توقع أن يصل الإصدار الجديد فور النشر.** كما رأيت، العامل الجديد ينتظر. اعرض زر تحديث، واجعل السيرفر يقبل العملاء القدامى.
- **تسجيل Service Worker بنطاق أوسع من اللازم.** العامل في جذر الموقع يتحكم في كل الصفحات، فإذا أخطأ في استراتيجية تعطل الموقع كله. ابدأ بنطاق التطبيق فقط كما فعلنا بـ `/app/`.

## جدول قرار Decision Table وقائمة تحقق Checklist

| البيانات                             | المكان المناسب                                                             | لماذا                                                   |
| ------------------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------- |
| معرف الجلسة                          | Cookie مع HttpOnly وSecure وSameSite                                       | يحتاجه السيرفر مع كل طلب، ولا يجب أن تراه JavaScript    |
| اللغة، الوضع الليلي، آخر تبويب مفتوح | localStorage                                                               | قيم صغيرة تقرأ مرة عند البداية                          |
| مسودة نموذج في تبويب واحد            | sessionStorage                                                             | تختفي مع إغلاق التبويب ولا تختلط بين تبويبين            |
| السلة                                | IndexedDB أو localStorage إذا كانت صغيرة، ونسخة في السيرفر للمستخدم المسجل | لا تحتاج أن تسافر مع كل طلب، والسيرفر يحفظها من الإخلاء |
| كتالوج أو طلبات تنتظر الإرسال        | IndexedDB                                                                  | كبيرة ومنظمة وتحتاج بحثاً ومعاملات                      |
| ملفات الواجهة                        | Cache API عبر Service Worker، الـ Cache أولاً                              | تعمل دون إنترنت وتتغير مع الإصدار فقط                   |
| ردود API للعرض دون إنترنت            | Cache API، الشبكة أولاً                                                    | حديثة عند الاتصال، ومحفوظة عند الانقطاع                 |

وقبل أن تطلق تطبيقاً يعتمد على التخزين في المتصفح راجع النقاط التالية:

- هل يعمل التطبيق إذا وجد التخزين فارغاً تماماً، كما بعد الإخلاء أو مسح البيانات؟
- هل كل كتابة مهمة محاطة بـ `try...catch` تلتقط `QuotaExceededError` وغيره؟
- كم حجم ترويسة Cookie في طلباتك؟ افتح لوحة Network وانظر إلى طلب صورة.
- هل يحذف حدث `activate` الإصدارات القديمة من Cache API؟
- هل يوجد زر أو رسالة تحديث عندما ينتظر عامل جديد، وهل يقبل السيرفر طلبات الإصدار السابق؟
- هل يمسح تسجيل الخروج البيانات الخاصة بالمستخدم، بما فيها ردود API المحفوظة؟
- هل جربت التطبيق والسيرفر متوقف، وفي Safari على iPhone بعد أيام من عدم الاستخدام؟

## تدريب عملي Practice

- أضف إلى التطبيق نموذجاً يسجل أن طلباً جهز، واحفظ التسجيل في IndexedDB عندما تفشل الشبكة، ثم أرسله عند الحدث `online`، وجربه بإيقاف السيرفر وتشغيله.
- غير استراتيجية `/api/` إلى القديم ثم التحديث، وقارن وقت `servedAt` المعروض مع الوقت الحقيقي بعد عدة زيارات.
- أضف في التطبيق مستمعاً للحدث `controllerchange` يعيد تحميل الصفحة تلقائياً بعد الضغط على زر التحديث، وشغل `measure.mjs` لترى أن الخطوة 6j لم تعد تحتاج إعادة تحميل يدوية.
- افتح لوحة Application في DevTools على موقعك الحالي، وانظر إلى ما يحفظه في كل نوع، وكم حجمه، وهل فيه شيء لا يجب أن يكون هناك.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- ملفات تعريف الارتباط للسيرفر فقط: في Chrome 156 كان الحد 4096 بايت للملف، وما زاد يهمل بصمت، ومن 300 ملف بقي 176، وكل طلب حمل بعدها 4,750 بايت.
- localStorage بسيطة ومتزامنة: توقفت عند 5,111,895 حرفاً بنفس العدد للعربي واللاتيني، ثم `QuotaExceededError`.
- IndexedDB للبيانات الكبيرة والمنظمة، وCache API لردود HTTP، والاثنتان تشتركان في حصة واحدة، ورفضتا الكتابة عند 49 MiB من حصة 50 MiB بينما `estimate()` تقول 10.7 GB.
- التخزين في المتصفح نسخة مؤقتة: المسح والإخلاء يحذفان كل الأنواع معاً، وSafari يحذف بيانات JavaScript بعد سبعة أيام بدون تفاعل.
- Service Worker جعل التطبيق يعمل والسيرفر متوقف، مع آخر نسخة من البيانات بتاريخها، والصفحة التي لا يتحكم فيها أعطت خطأ الاتصال.
- الإصدار الجديد من العامل ينتظر ولا يصل بإعادة التحميل، وتحتاج زر تحديث مع `skipWaiting` وسيرفراً يقبل العملاء القدامى.

وفي مرحلة [هندسة الواجهات وتطبيقات الويب](https://arabroot.io/start/#stage-web-frontend) تجد المقالات التي تكمل هذا المقال:

[![](https://arabroot.io/content/images/2026/10/sessions-cookies-jwt-cover-cover.webp)دليلكيف يتذكرك الموقع بعد تسجيل الدخول؟ الجلسات وJWTبروتوكول HTTP لا يتذكر أحداً، فكيف يبقى المستخدم مسجلاً بين الصفحات، ومتى تختار الجلسة على السيرفر ومتى تختار JWT.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AC%D9%84%D8%B3%D8%A7%D8%AA-%D9%88%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D8%AA%D8%B9%D8%B1%D9%8A%D9%81-%D8%A7%D9%84%D8%A7%D8%B1%D8%AA%D8%A8%D8%A7%D8%B7-%D9%88-jwt-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/cors-cover-cover.webp)دليلCORS: لماذا يرفض المتصفح طلبك، وكيف تسمح به بأمانلماذا يمنع المتصفح صفحتك من قراءة رد ال API، وكيف تفتح CORS هذا الباب لمصادر محددة دون أن تفتحه لكل موقع.](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-cors-%D9%88%D8%B3%D9%8A%D8%A7%D8%B3%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1-%D8%A7%D9%84%D9%88%D8%A7%D8%AD%D8%AF/)[![](https://arabroot.io/content/images/2026/10/js-event-loop-cover-cover.webp)دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.](https://arabroot.io/articles/%D8%AD%D9%84%D9%82%D8%A9-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-event-loop-%D9%81%D9%8A-javascript/)[![](https://arabroot.io/content/images/2026/10/csr-ssr-ssg-cover-cover.webp)دليلCSR و SSR و SSG: أين ترسم صفحة الويب ومتى؟قسنا نفس صفحة المتجر مرسومة في المتصفح وفي السيرفر ومولدة مسبقاً على شبكة بطيئة في Chrome، لترى متى يظهر المحتوى ومتى تعمل الأزرار وماذا يرى محرك البحث.](https://arabroot.io/articles/%D8%A7%D9%84%D9%81%D8%B1%D9%82-%D8%A8%D9%8A%D9%86-csr-%D9%88-ssr-%D9%88-ssg-%D9%88%D9%85%D8%AA%D9%89-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%83%D9%84%D8%A7-%D9%85%D9%86%D9%87%D8%A7/)[![](https://arabroot.io/content/images/2026/10/http-basics-cover-cover.webp)دليلHTTP وHTTPS ببساطة: ماذا يحدث بين الرابط والصفحةماذا يحدث بين كتابة الرابط وظهور الصفحة، وكيف تقرأ الطلب والرد ورموز مثل 502 و504، ولماذا يكلف الاتصال الجديد أكثر.](https://arabroot.io/articles/http-%D9%88-https-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة في Chrome 156.0.8078.49 بدون واجهة عبر DevTools Protocol، مع سيرفر Node.js v22.14.0.