Images Fail on Mobile in Pages Hidden with display:none
The bugs that waste the most time are not the ones whose cause I cannot find, but the ones every diagnostic tool says are fine. The file returns 200, opening the direct link shows the image, fetch works, and new Image works. Yet the mobile screen stays black, because the problem is not the file. It is the timing of visibility.
I took on a job rebuilding a website for a place to stay. It was a single-page site divided into 6 subpages, with menu clicks switching pages in the browser without loading a new page. Once I finished, everything looked good on desktop, with every image present. When I sent the link to the client to open on mobile, the rooms and gallery pages were full of blank black images. After a while, some images started appearing on their own. This was worse than images not appearing at all, because it made visitors think the site was broken.
I have made up all the filenames, page names, and variable names in this article, but the debugging sequence and the code used to fix it are what I actually used.
The page structure behind the problem
This structure is very ordinary. Anyone building a one-page site with several subpages has probably written something similar: put every page in the HTML from the start, hide them all, and show only the active page.
<!-- index.html — ทุกหน้าอยู่ใน DOM ตั้งแต่แรก ซ่อนด้วย CSS -->
<main>
<section id="p-home" class="page on">...</section>
<section id="p-rooms" class="page">
<div class="big"><img id="rmain" src="" alt="" width="1200" height="900" loading="lazy"></div>
<div id="rthumbs"></div>
</section>
<section id="p-gallery" class="page">
<img src="/img/gallery/g-01.jpg" alt="..." width="1200" height="800" loading="lazy">
<img src="/img/gallery/g-02.jpg" alt="..." width="1200" height="800" loading="lazy">
</section>
</main>
/* style.css — หน้าไหนไม่ active คือหายไปจากผังทั้งก้อน */
.page { display: none; }
.page.on { display: block; }
The page-switching function is straightforward too. Remove the on class from the old page, add it to the new page, and that is it.
// app.js — เวอร์ชันแรก สลับหน้าอย่างเดียว ไม่ยุ่งกับรูป
const order = ['home', 'rooms', 'gallery', 'map', 'reservation', 'contact'];
let cur = 'home';
function go(id) {
if (!order.includes(id) || id === cur) return;
document.getElementById('p-' + cur).classList.remove('on');
document.getElementById('p-' + id).classList.add('on');
cur = id;
try { history.replaceState(null, '', '#' + id); } catch (_) {}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
Every page worked on desktop. On mobile, opening the rooms page showed empty frames of the correct size, with no broken image icon and not a single error in the console. This is where I started going down the wrong path.
Two wrong turns: successful fetch calls and misread curl output
The first thing I suspected was the files. I opened DevTools on mobile through remote debugging and ran tests directly in the console.
// ทดสอบใน console ของเครื่องที่มีอาการ — ทั้งสองทางผ่านหมด
const u = '/img/gallery/g-01.jpg';
fetch(u).then(r => console.log('[fetch]', r.status, r.headers.get('content-type')));
// [fetch] 200 image/jpeg
const t = new Image();
t.onload = () => console.log('[new Image] ok', t.naturalWidth, 'x', t.naturalHeight);
t.onerror = () => console.log('[new Image] FAIL');
t.src = u;
// [new Image] ok 1200 x 800
Both fetch and new Image succeeded. I got 200, the correct content-type, and the actual dimensions. That meant the file was intact, the network path was fine, permissions were fine, and that browser could load that image file. At that point, I nearly concluded it was a server issue, because all the evidence seemed to point that way.
My mistake was testing whether the file could load, when the real question was why that element was not initiating a load. Calling fetch or new Image creates a new request outside the page layout, implicitly bypassing every mechanism responsible for the problem. The results were true but had nothing to do with the symptoms.
The second wrong turn was misreading the response headers. Still suspicious of the server, I used curl to inspect the response headers and found a line that sent me off track for another day.
$ curl -I https://example.com/img/gallery/g-01.jpg
HTTP/1.1 200 OK
content-type: image/jpeg
content-length: 184213
cache-control: public, max-age=2592000
As soon as I saw HTTP/1.1, I thought I had found it. HTTP/1.1 has a limit of around 6 connections per origin, and the gallery page had 26 images. A backed-up queue would neatly explain why some images appeared late and others never appeared. I had already planned to move the images to subdomains to spread out the connections before realizing I had misread the output.
# curl ไม่ต่อรอง h2 ให้เองถ้าไม่สั่ง — ต้องบอกมันตรง ๆ
$ curl -I --http2 https://example.com/img/gallery/g-01.jpg
HTTP/2 200
content-type: image/jpeg
# ของจริงดูที่เบราว์เซอร์เท่านั้น: DevTools > Network > คลิกขวาที่หัวตาราง > ติ๊ก Protocol
# ทุก request ของหน้านี้ขึ้น h2 และไม่มี request ไหนค้างในสถานะ pending เลยสักตัว
The endpoint had been using HTTP/2 all along. I saw HTTP/1.1 because curl would not negotiate h2 by itself without --http2. More importantly, if this really were a connection queue issue, I should have seen at least some requests sitting in a pending state. But in the Network tab on the affected device, not a single request for those images appeared. They were not stalled or failing; they had never been sent in the first place.
This was the turning point for the whole job. A stalled or failed request is a network issue. But if the request count for the missing images is 0, the browser never decided to load them at all. The problem is entirely on the rendering side. Fixing the server or switching CDNs another ten times would never solve it.
The culprit: lazy-load does not fire when the page changes from none to block
Once I knew it was a rendering issue, I went back to what I had put in the tags myself: loading="lazy", a useful feature I had grown used to adding automatically to every image. Its mechanism is that the browser watches whether the element is near the viewport. If it is not, no request is sent. Behind this is IntersectionObserver.
An element in a subtree with display: none has no box in the page layout, no position, and no dimensions. The observer therefore reports that it is outside the viewport, which is technically correct. When I remove display: none, the element returns to the layout. But returning an element that has already been marked “no need to load yet” to the layout does not make the browser immediately reconsider that decision. It waits for the next relayout, which can sometimes take a long time.
// พิสูจน์ว่ารูปยังไม่ถูกโหลด: naturalWidth เป็น 0 คือยังไม่มีข้อมูลภาพ
function auditImages(pageId) {
const rows = [...document.querySelectorAll(`#p-${pageId} img`)].map(im => ({
src: im.getAttribute('src')?.slice(-24),
loading: im.loading, // lazy | eager
complete: im.complete, // true ทั้งที่ยังไม่มีภาพได้ ถ้า src ว่าง
natural: im.naturalWidth, // 0 = ยังไม่โหลดจริง ตัวนี้เชื่อได้
}));
console.table(rows);
}
auditImages('gallery');
// ผลบนมือถือหลังกดเข้าหน้า gallery: natural = 0 ทั้ง 26 แถว
// รออีก 5 วินาทีแล้วเรียกซ้ำ: บางแถวกลายเป็น 1200 เอง โดยไม่ได้ทำอะไรเพิ่ม
The symptom of “some images appear on their own after waiting” fits this exactly. It waits for a relayout triggered by something else, such as the user scrolling or a font finishing loading. Once that relayout happens, the observer recalculates and sends the requests. The user therefore sees images appearing seemingly at random after several seconds.
Why it only happens on mobile
This question matters, because without answering it, I cannot be confident I have fixed the right thing. Desktop and mobile differ in two ways.
The first is viewport size. A desktop screen is 900 pixels tall or more. When a new page opens, several images in the first row are already within the range considered “almost visible,” so the browser sends requests immediately. On mobile, the screen is around 600 pixels tall and the images are arranged in a single column. The second image onward falls outside that range, leaving far more images in the lazy state on mobile.
The second is that the lazy-load prefetch distance is not fixed. Chromium adjusts the distance according to its estimate of network quality. If it thinks the connection is 4G or slower, it shortens the distance to save data. A device on fast Wi-Fi and one on a mobile connection therefore behave differently. This is hard to reproduce if I only test in DevTools mobile emulation, which still uses the desktop machine's connection.
Works by chance, not by design
- Tall viewport puts the first row within range
- Fast connection gives a wider prefetch range
- Frequent mouse movement brings frequent relayouts
- Images appear immediately, despite the same mechanism
Hits every condition
- Short viewport leaves almost every image out of range
- Slower connection narrows the prefetch range
- Sits idle, with nothing to trigger a relayout
- Empty frames appear first, then images later
Simply setting loading to eager is not enough
Once the cause is clear, the first thing anyone would try is changing loading to eager when opening the page. I tried that first too, and it did not help.
// เวอร์ชันที่ผมลองก่อน แล้วไม่ได้ผล
document.querySelectorAll('#p-gallery img[loading="lazy"]')
.forEach(im => { im.loading = 'eager'; });
// naturalWidth ยังเป็น 0 เหมือนเดิม บาง build ได้บ้างไม่ได้บ้าง เชื่อถือไม่ได้
The reason is that loading takes effect when the browser starts evaluating src. Changing the attribute afterward on an element that has already passed that stage does not tell it to start over. The only reliable way to make it start over is to change the value of src, so I removed it and put it back.
// บังคับให้เริ่มกระบวนการโหลดใหม่ — ถอด src ออกก่อน แล้วค่อยใส่ค่าเดิมกลับ
function forceLoad(root) {
root.querySelectorAll('img').forEach(im => {
if (im.naturalWidth) return; // โหลดไปแล้ว อย่าไปยุ่ง
const s = im.getAttribute('src');
if (!s) return; // รูปที่ JS จะเซ็ต src ทีหลัง ข้ามไป
im.loading = 'eager';
im.removeAttribute('src'); // สองบรรทัดนี้ต้องคู่กัน
im.src = s; // การเซ็ตค่าใหม่คือตัวสั่งให้เริ่มโหลด
});
}
Simply setting im.src = im.src leaves the value unchanged. Some engines do not treat this as a change and will not restart loading. Calling removeAttribute and then restoring it makes the value actually change twice, so it works more reliably. Always check naturalWidth first, or images that have already loaded will flicker every time the user switches pages.
The actual fix needs two things working together
forceLoad alone is not enough, because it only fixes “initiating a load.” The user still has to wait for the actual download. Opening a page and watching images trickle in one by one is still a poor experience, so I added a cache warmer that starts after the first page finishes loading.
// อุ่นแคชหลังหน้าแรกนิ่งแล้ว 1 วินาที — ทีละ 4 ตัว ไม่แย่งแบนด์วิดท์กับหน้าที่ผู้ใช้กำลังดู
setTimeout(() => {
const urls = [];
ROOMS.forEach(r => { for (let k = 1; k <= r.n; k++) urls.push(`/img/rooms/${r.id}/${r.id}-${k}.jpg`); });
GALLERY.forEach(g => urls.push(g.src));
let i = 0;
const next = () => {
if (i >= urls.length) return;
const im = new Image();
im.onload = im.onerror = next; // ต่อคิวทั้งสำเร็จและพัง อย่าให้ค้างเพราะรูปเดียว
im.src = urls[i++];
};
for (let c = 0; c < 4; c++) next(); // 4 เส้นพร้อมกันกำลังดี
}, 1000);
With these two working together, when the user opens a page, forceLoad initiates loading and the request finds the image already in the cache. The image therefore appears almost immediately. All that remains is to hook forceLoad into the page-switching function.
// app.js — เวอร์ชันที่ใช้จริง
function go(id) {
if (!order.includes(id) || id === cur) return;
const c = document.getElementById('p-' + cur);
const n = document.getElementById('p-' + id);
if (c) c.classList.remove('on');
if (n) n.classList.add('on');
cur = id;
if (id === 'rooms') renderRoom(curRoom); // หน้านี้ src มาจาก JS ต้อง render ก่อน
if (n) forceLoad(n); // แล้วค่อยบังคับโหลดทั้งหน้า
try { history.replaceState(null, '', '#' + id); } catch (_) {}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
Images whose src is set by JS need separate handling
There is one kind of image that forceLoad cannot help with: the main image, where I reuse a single element and change its src according to the room the user selects. While the page is hidden, src is an empty string, so forceLoad skips it as written. The rendering function has to handle it itself.
// render รูปห้อง — element เดิม เปลี่ยน src ตามห้องที่เลือก
function renderRoom(i) {
const r = ROOMS[i];
const main = document.getElementById('rmain');
main.loading = 'eager'; // ตัวนี้ผู้ใช้ต้องเห็นแน่ ๆ ไม่ต้อง lazy
main.src = `/img/rooms/${r.id}/${r.id}-1.jpg`; // เซ็ตสดทุกครั้ง ไม่เช็กว่าซ้ำเดิมไหม
main.alt = r.alt;
document.getElementById('rthumbs').innerHTML = r.thumbs
.map((t, k) => `<button class="${k ? '' : 'on'}" data-full="${t}">
<img src="${t}" alt="${r.alt} รูปที่ ${k + 1}" loading="eager"></button>`)
.join('');
}
At this point, I stopped using loading="lazy" for thumbnails on this page altogether. There are no more than ten small images, and they are images the user deliberately opened the page to see. Saving ten requests in exchange for a page that looks broken is not worth it.
An automated check before calling it done
I cannot check a bug like this by eye, because it usually passes on the developer's machine. So I wrote a check to run before every handoff: open the site in a real browser, shrink the screen to mobile size, click through every page, and count images whose naturalWidth is zero.
// tools/check-images.cjs — ต้องได้ 0 ทุกหน้าก่อนถึงจะส่งงาน
const { chromium } = require('playwright');
const PAGES = ['home', 'rooms', 'gallery', 'map', 'reservation', 'contact'];
(async () => {
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 640 }, isMobile: true });
const p = await ctx.newPage();
const errs = [];
p.on('pageerror', e => errs.push('PAGEERROR ' + e.message));
await p.goto('https://example.com/?v=' + Date.now(), { waitUntil: 'load' });
await p.waitForTimeout(2500); // เผื่อเวลาให้ตัวอุ่นแคชทำงาน
let bad = 0;
for (const id of PAGES) {
await p.evaluate(x => window.go(x), id);
await p.waitForTimeout(1500); // ให้เวลาดาวน์โหลดจริง
const blank = await p.evaluate(x =>
[...document.querySelectorAll(`#p-${x} img`)]
.filter(im => im.naturalWidth === 0)
.map(im => im.getAttribute('src') || '(src ว่าง)'), id);
console.log(`[${blank.length ? 'FAIL' : 'OK'}] ${id} — รูปที่ยังไม่โหลด ${blank.length}`);
blank.forEach(s => console.log(' ', s));
bad += blank.length;
}
if (errs.length) console.log(errs.join('\n'));
await b.close();
process.exit(bad || errs.length ? 1 : 0);
})();
Another related issue while I was repeatedly working on the fix was cached HTML. For a while, it fooled me into thinking the changes had no effect, even though the file had been uploaded. While I was still making frequent changes, I disabled HTML caching.
# .htaccess — ระหว่างช่วงแก้งานถี่ ๆ ให้ HTML หมดอายุทันที รูปกับ CSS ยังแคชได้ตามเดิม
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/html "access plus 0 seconds"
ExpiresByType image/jpeg "access plus 30 days"
ExpiresByType text/css "access plus 7 days"
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(html|php)$">
Header set Cache-Control "no-cache, must-revalidate"
</FilesMatch>
</IfModule>
Before-and-after numbers, and the sequence for the next job
The sequence I use whenever images fail to appear can be laid out as these steps.
- When images do not appear, always check the request count in the Network tab first. If it is zero, it is a rendering issue, not a server issue.
- Stop using
fetchornew Imageas evidence that the file is fine. They answer a different question from the one the symptoms raise. - Read the full
curloutput. To see the actual protocol, add--http2or check the Protocol column in DevTools. - Check the
naturalWidthof everyimgon the page just opened. A value of 0 means it has not actually loaded. Do not trustcomplete. - Apply both fixes together: warm the cache ahead of time, and force
removeAttribute('src')followed by restoring the original value when opening the page. - For images whose
srccomes from JS, have the rendering function set it afresh every time. Do not rely on the forced-loading loop. - Add a check using a real browser at mobile screen size at the end of the job. Wait for it to report zero before calling the work done.
Personally, I see the root of this issue as using display: none for entire subpages. That tells the browser “these things do not exist,” while I still expect it to prepare them for me. If I were writing it again from scratch and could accept the complexity, I would choose an approach that does not require fighting the browser like this: split it into real pages in separate files, or remove the DOM of unused pages and rebuild it when they open. But if I already have a site like this and rebuilding it is not worth the effort, the two measures above do work without changing the existing structure. Feel free to adjust the queue count or delay to suit your own work. If I have left anything out or made a typo, I will come back and correct it later.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first