Hello friends,
To conclude, this small experiment was meant purely as a proof of concept, not as a benchmark and not as a replacement for existing solutions.
The goal was to understand the architectural difference between a classic server-side rendered UI and a fetch/JSON-based microservice UI on the same Harbour/DBF backend. Both approaches use identical data and business logic; the difference is solely how the UI communicates with the server.
The attached network screenshot illustrates this clearly. The entire UI consists of a single HTML document and one fetch request to the Harbour microservice via a very thin PHP proxy. No external assets are loaded, which keeps the setup GDPR-neutral and makes the request footprint easy to understand. The absolute timings are not the point here; they simply show that frontend overhead is negligible compared to backend processing.
What surprised me most was how straightforward this transition turned out to be. By deliberately reducing the UI to what is actually required, the code became smaller, easier to reason about, and more transparent. This does not argue against HIX or server-side rendering at all — those approaches solve different problems. The PoC merely shows that for certain use cases, such as internal tools, diagnostics, or microservice frontends, a very lightweight fetch-based UI is a viable and clean alternative.
For me, the main takeaway is that Harbour and DBF work equally well in both models. The choice between server-rendered HTML and fetch/JSON is primarily an architectural decision, not a performance one.
Best regards,
Otto
agenda.php


HIX – Server Side Rendering
Browser
|
| HTTP Request (Action)
v
HIX / Harbour
|
| DBF Zugriff + Logik
v
HTML komplett gerendert
|
v
Browser (neue Seite)
Microservice – Fetch / JSON
Browser (UI bleibt geladen)
|
| fetch() / JSON
v
Harbour Microservice
|
| DBF Zugriff + Logik
v
JSON Response
|
v
Browser (DOM-Update)
<?php
// agenda.php – UI only, DSGVO-neutral (no external assets)
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agenda Microservice – Test UI</title>
<style>
:root{
--primary:#6366f1;
--primary-hover:#4f46e5;
--danger:#ef4444;
--bg:#0f172a;
--card:#1e293b;
--text:#f8fafc;
--muted:#94a3b8;
--input:#334155;
--border:#475569;
}
body{
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
background:var(--bg);
color:var(--text);
margin:0;
padding:2rem;
min-height:100vh;
display:flex;
flex-direction:column;
align-items:center;
}
h1{
margin:0;
font-weight:600;
background:linear-gradient(to right,#818cf8,#c4b5fd);
-webkit-background-clip:text;
-webkit-text-fill-color:transparent;
}
.subtitle{
color:var(--muted);
margin:.25rem 0 2rem;
font-size:.9rem;
}
.container{
width:100%;
max-width:1200px;
display:grid;
grid-template-columns:380px 1fr;
gap:2rem;
}
@media(max-width:900px){
.container{grid-template-columns:1fr}
}
.card{
background:var(--card);
padding:1.5rem;
border-radius:1rem;
border:1px solid rgba(255,255,255,.05);
box-shadow:0 10px 30px rgba(0,0,0,.25);
}
.card-header{
font-size:1.25rem;
font-weight:600;
margin-bottom:1rem;
border-bottom:1px solid var(--border);
padding-bottom:.5rem;
}
label{
display:block;
margin:.75rem 0 .4rem;
font-size:.85rem;
color:var(--muted);
}
input{
width:100%;
padding:.7rem;
border-radius:.5rem;
border:1px solid var(--border);
background:var(--input);
color:#fff;
}
.btn{
margin-top:1rem;
width:100%;
padding:.75rem;
border:none;
border-radius:.5rem;
font-weight:600;
cursor:pointer;
display:flex;
align-items:center;
justify-content:center;
gap:.5rem;
}
.btn-primary{
background:var(--primary);
color:#fff;
}
.btn-primary:hover{background:var(--primary-hover)}
.btn-sm{
padding:.35rem .6rem;
font-size:.8rem;
width:auto;
}
.btn-edit{
background:transparent;
border:1px solid var(--primary);
color:var(--primary);
}
.btn-danger{
background:transparent;
border:1px solid var(--danger);
color:var(--danger);
}
.search-bar{
display:flex;
gap:.5rem;
margin-bottom:1rem;
}
.search-bar input{flex-grow:1}
.clear-btn{
background:var(--input);
border:1px solid var(--border);
border-radius:.5rem;
color:var(--muted);
cursor:pointer;
padding:0 .75rem;
}
.table-wrap{
max-height:560px;
overflow-y:auto;
border:1px solid var(--border);
border-radius:.5rem;
}
table{
width:100%;
border-collapse:collapse;
}
thead th{
position:sticky;
top:0;
z-index:2;
background:var(--card);
}
tbody{position:relative;z-index:1}
th,td{
padding:.9rem;
border-bottom:1px solid var(--border);
text-align:left;
}
th{
font-size:.8rem;
text-transform:uppercase;
color:var(--muted);
}
tbody tr{
transition:background-color .15s ease;
}
tbody tr:hover{
background:rgba(99,102,241,.12);
}
tbody tr:hover td{color:#fff}
.actions-cell{
display:flex;
gap:.5rem;
justify-content:flex-end;
}
.empty-state{
text-align:center;
padding:2rem;
color:var(--muted);
}
/* Toast */
.toast{
position:fixed;
right:1.5rem;
bottom:1.5rem;
background:#1f2937;
color:#fff;
padding:.75rem 1rem;
border-radius:.5rem;
box-shadow:0 10px 25px rgba(0,0,0,.35);
opacity:0;
transform:translateY(10px);
transition:opacity .25s,transform .25s;
z-index:9999;
font-size:.9rem;
}
.toast.show{opacity:1;transform:translateY(0)}
.toast.success{border-left:4px solid #22c55e}
.toast.error{border-left:4px solid #ef4444}
.toast.info{border-left:4px solid var(--primary)}
</style>
</head>
<body>
<h1>Agenda Microservice</h1>
<div class="subtitle">Minimal Test UI for Harbour / DBF Microservice (no external assets)</div>
<div class="container">
<!-- FORM -->
<div class="card">
<div class="card-header" id="formTitle">Add Contact</div>
<form id="contactForm">
<input type="hidden" id="id">
<label>First Name</label>
<input id="firstname" required>
<label>Last Name</label>
<input id="lastname" required>
<label>Phone</label>
<input id="phone">
<label>Email</label>
<input id="email">
<button class="btn btn-primary">💾 Save Contact</button>
</form>
</div>
<!-- LIST -->
<div class="card">
<div class="card-header">Contacts</div>
<div class="search-bar">
<input id="search" placeholder="Search contacts…">
<button class="clear-btn" id="clearSearch">✖</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Phone / Email</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody id="rows">
<tr><td colspan="3" class="empty-state">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
const API="agenda_proxy.php";
const el=id=>document.getElementById(id);
const esc=s=>String(s).replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
/* Toast */
let toastTimer=null;
function showToast(msg,type="success"){
let t=document.querySelector(".toast");
if(!t){
t=document.createElement("div");
t.className="toast";
document.body.appendChild(t);
}
t.className=`toast ${type} show`;
t.textContent=msg;
clearTimeout(toastTimer);
toastTimer=setTimeout(()=>t.classList.remove("show"),2200);
}
/* API */
async function api(action,p={}){
const r=await fetch(API,{
method:"POST",
headers:{'Content-Type':'application/json'},
body:JSON.stringify({action,...p})
});
const j=await r.json();
if(!j.success) throw j.message;
return j;
}
/* Render */
function rowHtml(c){
const name=`${esc(c.FIRSTNAME||"")} ${esc(c.LASTNAME||"")}`.trim();
const pe=`${esc(c.PHONE||"")}${c.PHONE&&c.EMAIL?" / ":""}${esc(c.EMAIL||"")}`;
return `<tr>
<td>${name}</td>
<td>${pe}</td>
<td class="actions-cell">
<button class="btn btn-sm btn-edit" data-e="${esc(c.ID)}">✏️</button>
<button class="btn btn-sm btn-danger" data-d="${esc(c.ID)}">🗑️</button>
</td>
</tr>`;
}
/* Load */
async function load(){
const j=await api("list",{search:el("search").value});
el("rows").innerHTML=j.items.length
? j.items.map(rowHtml).join("")
: `<tr><td colspan="3" class="empty-state">No entries</td></tr>`;
}
/* Live search */
let searchTimer=null;
el("search").addEventListener("input",()=>{
clearTimeout(searchTimer);
searchTimer=setTimeout(load,250);
});
el("clearSearch").onclick=()=>{el("search").value="";load();};
/* Edit */
async function doEdit(id){
const j=await api("get",{id});
const c=j.item||{};
el("id").value=c.ID||"";
el("firstname").value=c.FIRSTNAME||"";
el("lastname").value=c.LASTNAME||"";
el("phone").value=c.PHONE||"";
el("email").value=c.EMAIL||"";
showToast("Edit mode","info");
}
/* Row actions */
el("rows").onclick=async e=>{
const b=e.target.closest("button");
if(!b) return;
if(b.dataset.e) await doEdit(b.dataset.e);
if(b.dataset.d && confirm("Delete contact?")){
await api("delete",{id:b.dataset.d});
showToast("Contact deleted","success");
await load();
}
};
/* Save */
el("contactForm").onsubmit=async e=>{
e.preventDefault();
const isEdit=!!el("id").value;
await api("save",{
id:el("id").value,
firstname:el("firstname").value,
lastname:el("lastname").value,
phone:el("phone").value,
email:el("email").value
});
el("contactForm").reset();
el("id").value="";
showToast(isEdit?"Contact updated":"Contact saved","success");
await load();
};
/* Boot */
load();
</script>
</body>
</html>agenda_proxy.php
<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/json; charset=utf-8');
/* --------------------------------------------------
RAW INPUT
-------------------------------------------------- */
$inputRaw = file_get_contents('php://input') ?: '{}';
/* --------------------------------------------------
Microservice URL
-------------------------------------------------- */
$msUrl = 'http://127.0.0.1:9090/agenda'; // <-- ROUTE im MS
/* --------------------------------------------------
Stream Context
-------------------------------------------------- */
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nConnection: close\r\n",
'content' => $inputRaw,
'timeout' => 300,
'ignore_errors' => true
]
]);
/* --------------------------------------------------
CONNECT
-------------------------------------------------- */
$T0 = hrtime(true);
$fp = @fopen($msUrl, 'rb', false, $ctx);
if (!$fp) {
http_response_code(502);
echo json_encode([
'success' => false,
'message' => 'CONNECT_FAILED',
'error' => error_get_last()['message'] ?? 'unknown'
], JSON_UNESCAPED_UNICODE);
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
exit;
}
/* --------------------------------------------------
RESPONSE HEADER
-------------------------------------------------- */
$statusLine = $GLOBALS['http_response_header'][0] ?? '(no status)';
/* --------------------------------------------------
STREAM + HEAD/TAIL
-------------------------------------------------- */
$headMax = 1000;
$tailMax = 1000;
$head = '';
$tailBuf = '';
$total = 0;
while (!feof($fp)) {
$chunk = fread($fp, 8192);
if ($chunk === false) break;
$len = strlen($chunk);
if ($len === 0) continue;
if (strlen($head) < $headMax) {
$need = $headMax - strlen($head);
$head .= substr($chunk, 0, $need);
}
$tailBuf .= $chunk;
if (strlen($tailBuf) > $tailMax) {
$tailBuf = substr($tailBuf, -$tailMax);
}
$total += $len;
echo $chunk;
@flush();
}
fclose($fp);
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
/* --------------------------------------------------
-------------------------------------------------- */
$ms = round((hrtime(true) - $T0) / 1e6, 2);