EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Web / A Blank Page with No Errors, All Because I Named a Function top
Web

A Blank Page with No Errors, All Because I Named a Function top

by Ton-Or ·18 Oct 2026·8 min read
A Blank Page with No Errors, All Because I Named a Function top

The most expensive bugs leave no trace. No errors in the server logs, no 500, nothing wrong in the static checks. Just a blank page. And the answer is somewhere I had not looked—the actual browser console.

I had just finished changing a page and deployed it. When I opened it, all the content in the middle was gone, leaving only the header and footer. The server logs were completely quiet, every request had a status of 200, and view source showed every line of HTML. But there was nothing on screen. I spent almost an hour checking caches, opcache, and permissions before opening the browser console and finding the answer in three seconds. The cause was that I had named a function top.

I have changed all the filenames, data variable names, and page names in this article. But the symptoms, error messages, and checks are real.

Symptoms: every check passes, but the page is blank

The page uses a shared layout, with JavaScript rendering the central content from a data object loaded from a separate file. Opening the actual page gave me an empty shell.

<!-- layout: ข้างในเป็นสคริปต์ที่เรนเดอร์การ์ดทั้งหมดของหน้า -->
<div id="list"></div>

<script>
  // ฟังก์ชันช่วยเรียงลำดับ ตั้งชื่อสั้น ๆ ให้อ่านง่าย
  function top(items, n) {                    // <-- บรรทัดนี้ฆ่าทั้งไฟล์
    return items.slice().sort((a, b) => b.score - a.score).slice(0, n);
  }

  function render() {
    const box = document.getElementById('list');
    box.innerHTML = top(CATALOG.items, 12).map(cardHTML).join('');
  }
  document.addEventListener('DOMContentLoaded', render);
</script>

These were all the checks I had already run:

# 1) ไวยากรณ์ผ่าน
$ node --check public/js/page.js
# (ไม่มี output แปลว่าผ่าน)

# 2) ไฟล์ขึ้นไปจริง และเนื้อหาใหม่
$ curl -s "https://example.com/js/page.js?v=$RANDOM" | grep -c 'function top'
1

# 3) หน้าเว็บตอบ 200 และมี div ปลายทางอยู่จริง
$ curl -s "https://example.com/list?v=$RANDOM" | grep -c 'id="list"'
1

All three checks passed. That was why I spent almost an hour on the wrong track: the feeling that "I already checked it" led me to blame the cache and deployment instead.

Passing static checks does not mean it works

node --check only checks syntax, and it runs on Node, which has no window. That makes the entire class of errors caused by collisions with browser globals invisible to Node-side tools. The check must always match the failure. If the symptoms occur in a browser, the check must use a browser.

The real cause: a function name collides with a window property

When I opened the console in an actual browser, the message had been there all along.

# คอนโซลของเบราว์เซอร์ — ข้อความต่างกันไปตาม engine แต่ใจความเดียวกัน
Uncaught SyntaxError: cannot declare global binding 'top': property must be configurable
    at page.js:1

window.top is a property the browser defines from the start, and it is defined so that it cannot be changed or deleted. When my script declares function top() at the top level, it is asking to create a binding named top on the global object. That collides with an existing property that cannot be overwritten, so the engine throws an error during scope setup.

The nasty part is that it does not just break that function. Bindings for the entire script are prepared before the first line of code runs. When that step fails, not a single line in the entire block executes. The render function is never defined, and event listeners are never installed. The result is an empty box. And because this is purely a script error, the server has nothing to log.

The sequence behind the silenceLoad script200, complete file->Prepare bindingsBefore the first line runs->Collide with window.topThrow an error->Entire block never runsrender does not exist->Blank pageServer logs stay quiet

Names to avoid

These names often cause problems because they happen to be words people like to use for functions or variables.

// ชื่อที่ห้ามใช้ประกาศที่ระดับบนสุดของสคริปต์ เพราะ window มีอยู่แล้ว
const RESERVED = [
  'top', 'name', 'length', 'status', 'open', 'close', 'closed',
  'parent', 'self', 'frames', 'event', 'location', 'history', 'origin',
  'focus', 'blur', 'print', 'stop', 'scroll', 'external', 'menubar',
];
// ความรุนแรงไม่เท่ากัน:
//   top / parent / self / closed / length -> ประกาศไม่ได้เลย ทั้งบล็อกตาย
//   name / status / origin                -> ประกาศทับได้ แต่กลายเป็นการเขียนค่าใส่ window
//                                            แล้ว window.name จะบังคับให้เป็นสตริง = บั๊กเงียบ
//   event                                 -> ทับได้ แต่ไปพังโค้ดตัวอื่นที่อ่าน window.event

The trickier cases are name and status, because they do not blow up. var status = 0 becomes window.status = 0 and is immediately converted to the string "0". A strict comparison then fails to match. Someone could read the code for a month without seeing what is wrong. I set myself a simpler rule: declare nothing at the top level if I can avoid it.

// ทางแก้ที่ตรงที่สุด: ครอบทั้งไฟล์ไว้ในสโคปของตัวเอง ชื่ออะไรก็ไม่ชนแล้ว
(function () {
  'use strict';

  function top(items, n) {                   // อยู่ในสโคปของ IIFE ไม่แตะ window
    return items.slice().sort((a, b) => b.score - a.score).slice(0, n);
  }

  function render() {
    const box = document.getElementById('list');
    if (!box || !window.CATALOG) return;      // กันไว้ก่อน จะได้ไม่พังเงียบ
    box.innerHTML = top(window.CATALOG.items, 12).map(cardHTML).join('');
  }

  document.addEventListener('DOMContentLoaded', render);
  window.renderList = render;                 // เปิดเฉพาะตัวที่คนอื่นต้องเรียกจริง
})();
Before

Declare at the top level

  • Every name ends up on window
  • A browser collision kills the entire block
  • Can silently collide with other scripts
  • Node-side tools cannot see it
What I use

Wrap in its own scope

  • Internal names belong to that file alone
  • Use any name without memorizing a list
  • Expose only what is necessary on window
  • Easy to move to a module later

If the project can move to ES modules, this problem goes away on its own, because <script type="module"> already has its own scope. Declarations inside it do not create bindings on the global object. But for older projects with multiple ordinary script files, the least painful approach is to wrap each file in an IIFE.

A static check that helps

Although static checks are not enough, they are still useful as a very fast first check. I wrote a scanner that finds top-level declarations and compares them against a list of forbidden names.

# tools/scan_globals.py — ด่านแรก เร็ว แต่ไม่ใช่ด่านสุดท้าย
import re, sys, pathlib

RESERVED = {'top', 'name', 'length', 'status', 'open', 'close', 'closed', 'parent',
            'self', 'frames', 'event', 'location', 'history', 'origin', 'focus',
            'blur', 'print', 'stop', 'scroll', 'external', 'menubar'}
DECL = re.compile(r'^(?:function|var|let|const|class)\s+([A-Za-z_$][\w$]*)')

bad = 0
for p in pathlib.Path('public/js').rglob('*.js'):
    for i, line in enumerate(p.read_text(encoding='utf-8').splitlines(), 1):
        if line[:1] in ' \t':          # เยื้องอยู่ = ไม่ใช่ระดับบนสุด ข้ามไป
            continue
        m = DECL.match(line)
        if m and m.group(1) in RESERVED:
            print(f'[ERR] {p}:{i} ประกาศ "{m.group(1)}" ที่ระดับบนสุด ชนกับ global ของ window')
            bad += 1

print(f'[{"FAIL" if bad else "OK"}] พบปัญหา {bad} จุด')
sys.exit(1 if bad else 0)

It only catches straightforward declarations without indentation, which covers most cases I encounter in practice. But it does not recognize scripts embedded in template files or code generated at runtime, so do not rely on it as the only check.

The second trap: the script runs before the data arrives

After I fixed the function name, the page came back. But on another page, I ran into a blank page again. This time, there was not even an error in the console. The cause was something entirely different: order.

The shared layout loaded the data file at the very end of the page, while I had inserted the rendering script in the middle of the content. The rendering script therefore ran before the data arrived.

Browser reads the page from top to bottomRead firstRead laterPage contentdiv #listMid-page scriptRuns now, no CATALOG yetcatalog.js at layout endData arrives afterwardGets [], renders nothing; || [] hides the errorFix: move the script after the data file at layout end, and report missing data
The script inserted in the middle of the content runs before the data file at the bottom loads. The well-intentioned || [] default hides the evidence, leaving only a blank page.
<!-- ผิด: สคริปต์กลางหน้า รันก่อนไฟล์ข้อมูลท้ายหน้า -->
<section>
  <div id="list"></div>
  <script>
    // CATALOG ยังไม่มีตัวตนตอนบรรทัดนี้ทำงาน
    const items = (window.CATALOG && window.CATALOG.items) || [];
    document.getElementById('list').innerHTML = items.map(cardHTML).join('');
    // ได้ [] แล้วเรนเดอร์ว่าง — ไม่มี error เพราะเราดักไว้ด้วย || [] เรียบร้อย
  </script>
</section>

<!-- ท้ายเลย์เอาต์ -->
<script src="/js/catalog.js"></script>
<script src="/js/site.js"></script>

That well-intentioned || [] was exactly what kept it quiet. Without it, the script would have thrown an error on the first run. So I changed the approach entirely: I moved the script to the end of the layout and made it report missing data.

<!-- ถูก: ทุกสคริปต์ที่ใช้ข้อมูล ไปอยู่ท้ายเลย์เอาต์หลังไฟล์ข้อมูลเสมอ -->
<script src="/js/catalog.js"></script>
<script src="/js/site.js"></script>
<script>
  (function () {
    if (!window.CATALOG) {                         // ห้ามกลืนเงียบ
      console.error('[list] CATALOG ยังไม่ถูกโหลด — สคริปต์นี้วางผิดที่');
      return;
    }
    window.renderList();
  })();
</script>
An overly forgiving default hides the evidence

The pattern const x = window.DATA || [] looks safe, but it turns "broken and immediately obvious" into "blank for no apparent reason." If that value is required, check it and throw an error, or at least emit a console.error. Fallback values are for things that can genuinely be missing, not for hiding my own mistakes.

The standard check: open a real browser and read pageerror

After getting caught twice, I stopped guessing and created a check file for every project with web pages. It has one job: open the actual page in a real browser, collect pageerror events and console errors, and verify that what should render actually does.

// tools/page-check.cjs — ด่านสุดท้ายก่อนบอกว่าเสร็จ
const { chromium } = require('playwright');

const TARGETS = [
  { url: 'https://example.com/',     expect: '#hero',  min: 1  },
  { url: 'https://example.com/list', expect: '#list .card', min: 12 },
];

(async () => {
  const b = await chromium.launch();
  let bad = 0;

  for (const t of TARGETS) {
    const p = await b.newPage();
    const errs = [];
    p.on('pageerror', e => errs.push('PAGEERROR: ' + e.message));
    p.on('console', m => { if (m.type() === 'error') errs.push('CONSOLE: ' + m.text()); });

    await p.goto(t.url + '?v=' + Date.now(), { waitUntil: 'load', timeout: 35000 });
    await p.waitForTimeout(1200);
    const n = await p.evaluate(s => document.querySelectorAll(s).length, t.expect);

    const ok = n >= t.min && errs.length === 0;
    console.log(`[${ok ? 'OK' : 'FAIL'}] ${t.url} — ${t.expect} = ${n} (ต้องการ ${t.min})`);
    errs.forEach(e => console.log('       ', e));
    if (!ok) bad++;
    await p.close();
  }

  await b.close();
  console.log(bad ? `\n[FAIL] มีปัญหา ${bad} หน้า` : '\n[OK] ผ่านทุกหน้า');
  process.exit(bad ? 1 : 0);
})();

Before the fix, running it produced this output, which pinpointed the problem in a single line.

$ node tools/page-check.cjs
[OK] https://example.com/ — #hero = 1 (ต้องการ 1)
[FAIL] https://example.com/list — #list .card = 0 (ต้องการ 12)
        PAGEERROR: cannot declare global binding 'top': property must be configurable

[FAIL] มีปัญหา 1 หน้า
Count what should appear, not just whether text exists

At first, I only checked whether document.body.innerText had a length greater than zero. That always passed because the header and footer were still there. The check must count matches for a specific selector targeting what that page is supposed to display, and it must specify a minimum count. Otherwise, a page that renders only one card out of the twelve expected will still pass.

Build it into the process so it does not depend on memory

A check I have to remember to run might as well not exist. I hooked it up in two places.

# .git/hooks/pre-push — ด่านเร็ว ตรวจไฟล์ก่อนดันขึ้น
changed=$(git diff --name-only origin/main...HEAD)
echo "$changed" | grep -qE '\.(js|php|blade\.php|html)$' || exit 0

python tools/scan_globals.py || { echo "[BLOCK] ชื่อชนกับ global ของ window"; exit 1; }

# ด่านช้า ตรวจหน้าจริงหลัง deploy — ผูกไว้ท้ายสคริปต์ deploy
# node tools/page-check.cjs || echo "[WARN] หน้าจริงยังไม่ผ่าน ให้ย้อน deploy"

This is the sequence I actually follow every time before telling anyone the work is done.

  1. Run scan_globals.py as the first check. It takes less than a second and catches almost all naming collisions.
  2. Wrap every script file in an IIFE or move it to a module, exposing only what other code calls on window.
  3. Always move scripts that use data variables to the end of the layout, after the data file. Never insert them in the middle of the content.
  4. Remove fallbacks such as || [] from required values. Replace them with checks that report the problem.
  5. Deploy, then run page-check.cjs against the actual URL, not a local file. Every page must return OK.
  6. If there is a FAIL, read the PAGEERROR line before anything else. Do not start chasing caches or permissions yet.

The numbers from this job

1 wordThe function name that broke the page
0 errorRecorded in the server logs
200The status of every request
3 checksStatic checks that all passed
55 minutesTime lost before opening the console
3 secondsTime to find it after opening the console
20 namesForbidden names scanned for
2 placesWhere the check is hooked up

The lesson I took forward is that when I see a blank page, I should leave the server alone at first. A blank page with complete HTML and a status of 200 means the server-side code has already finished successfully. The problem lies entirely after that point. And the only place recording what happens during that stage is the browser console, somewhere I rarely look because it is not part of anyone's deployment process. Feel free to take these two check scripts and adapt the selectors and forbidden names to your own project. If I missed anything in the explanation or made a typo, I will come back and fix it later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first