Hydration mismatch terjadi ketika HTML yang di-render server berbeda dengan Virtual DOM yang dibangun browser pada render perdana. Pada arsitektur Server-Side Rendering (SSR)—baik menggunakan template engine backend maupun hybrid frontend (React/Vue/Inertia)—perbedaan zona waktu server dan browser pengguna sering menjadi sumber utama error Hydration failed because the initial UI does not match what was rendered on the server.

Akar Masalah: Desinkronisasi Timestamp Markup

Server backend secara standar berjalan dengan timezone UTC (time.UTC). Jika sebuah endpoint me-render data tanggal menggunakan fungsi lokal tanpa memperhitungkan posisi geografis klien, markup HTML yang dikirim ke browser berisi timestamp UTC:

<!-- Hasil render server (UTC) -->
<span id="login-time">2025-05-20 03:00:00 UTC</span>

Ketika JavaScript di browser mengeksekusi proses hidrasi, fungsi format waktu seperti Intl.DateTimeFormat atau library tanggal mengevaluasi tanggal berdasarkan waktu lokal pengguna (misalnya Asia/Jakarta / UTC+7):

// Evaluasi client browser
const clientRender = new Date(isoString).toLocaleString(); // "2025-05-20 10:00:00"

Perbedaan string teks antara node HTML server dan VDOM client memaksa runtime hydration membuang node terkait dan melakukan re-render penuh (DOM replacement), meningkatkan First Input Delay (FID) dan Cumulative Layout Shift (CLS).

Arsitektur Solusi: Sec-CH-Timezone dan Fallback Cookie

Untuk menyinkronkan output server dengan browser sebelum HTML dikirim, backend memerlukan informasi timezone pengguna pada fase HTTP request. Solusi paling bersih memanfaatkan HTTP Client Hints:

  1. Client Hint Header: Browser modern yang mendukung mengirim header Sec-CH-Timezone (berisi IANA timezone seperti Asia/Jakarta). Server wajib mendaftarkan header ini melalui respons Accept-CH: Sec-CH-Timezone.
  2. Fallback Cookie: Untuk request pertama sebelum Client Hint terkirim, atau browser yang belum mengaktifkannya, simpan timezone dari skrip client (Intl.DateTimeFormat().resolvedOptions().timeZone) ke dalam cookie tz.

Implementasi Middleware Timezone di Go Fiber

Middleware berikut mengekstrak timezone pengguna secara thread-safe dari context, memvalidasi IANA timezone identifier, dan menyimpannya ke c.Locals.

package middleware

import (
	"net/http"
	"time"

	"github.com/gofiber/fiber/v2"
)

const (
	TimezoneContextKey = "user_timezone"
	ClientHintHeader   = "Sec-CH-Timezone"
	FallbackCookie     = "tz"
)

// TimezoneResolver memeriksa Client Hint lalu fallback ke Cookie
func TimezoneResolver(defaultLoc *time.Location) fiber.Handler {
	return func(c *fiber.Ctx) error {
		// Instruksikan browser untuk mengirimkan Client Hint pada request berikutnya
		c.Set("Accept-CH", ClientHintHeader)
		c.Vary(ClientHintHeader)

		tzName := c.Get(ClientHintHeader)
		if tzName == "" {
			tzName = c.Cookies(FallbackCookie)
		}

		loc := defaultLoc
		if tzName != "" {
			if loadedLoc, err := time.LoadLocation(tzName); err == nil {
				loc = loadedLoc
			}
		}

		// ponytail: simpan pointer time.Location langsung di c.Locals, upgrade ke struct wrapper jika butuh locale/format tambahan
		c.Locals(TimezoneContextKey, loc)
		return c.Next()
	}
}

Passing Timezone ke Template Engine Fiber

Go Fiber menyediakan engine view seperti HTML (Go stdlib template). Integrasikan timezone resolver ke fungsi format tanggal global agar markup yang di-render backend menghasilkan format yang identik dengan browser.

package main

import (
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/template/html/v2"
	"your-module/middleware"
)

func setupApp() *fiber.App {
	engine := html.New("./views", ".html")

	// Helper template untuk format waktu berbasis location context
	engine.AddFunc("formatDate", func(t time.Time, loc *time.Location, layout string) string {
		if loc == nil {
			loc = time.UTC
		}
		return t.In(loc).Format(layout)
	})

	app := fiber.New(fiber.Config{
		Views: engine,
	})

	app.Use(middleware.TimezoneResolver(time.UTC))

	app.Get("/dashboard", func(c *fiber.Ctx) error {
		userLoc, ok := c.Locals(middleware.TimezoneContextKey).(*time.Location)
		if !ok {
			userLoc = time.UTC
		}

		// Event waktu tetap (misal disimpan dalam format UTC di database)
		eventTime := time.Date(2025, 5, 20, 10, 0, 0, 0, time.UTC)

		return c.Render("index", fiber.Map{
			"EventTime": eventTime,
			"UserLoc":   userLoc,
		})
	})

	return app
}

Contoh berkas views/index.html:

<!DOCTYPE html>
<html>
<body>
  <div id="app">
    <span class="timestamp">{{ formatDate .EventTime .UserLoc "2006-01-02 15:04:05 MST" }}</span>
  </div>
</body>
</html>

Verifikasi Menggunakan app.Test

Pengujian unit dapat memverifikasi bahwa respons markup berubah secara presisi mengikuti header Sec-CH-Timezone atau cookie, memastikan tidak ada desinkronisasi sebelum dikirim ke client.

package main

import (
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestTimezoneHydrationRender(t *testing.T) {
	app := setupApp()

	tests := []struct {
		name         string
		headerVal    string
		cookieVal    string
		expectedText string
	}{
		{
			name:         "Default ke UTC jika hint kosong",
			headerVal:    "",
			cookieVal:    "",
			expectedText: "2025-05-20 10:00:00 UTC",
		},
		{
			name:         "Resolusi via Sec-CH-Timezone (WIB)",
			headerVal:    "Asia/Jakarta",
			cookieVal:    "",
			expectedText: "2025-05-20 17:00:00 WIB",
		},
		{
			name:         "Fallback ke cookie tz jika header tidak ada",
			headerVal:    "",
			cookieVal:    "America/New_York",
			expectedText: "2025-05-20 06:00:00 EDT",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
			if tc.headerVal != "" {
				req.Header.Set("Sec-CH-Timezone", tc.headerVal)
			}
			if tc.cookieVal != "" {
				req.AddCookie(&http.Cookie{Name: "tz", Value: tc.cookieVal})
			}

			resp, err := app.Test(req, -1)
			if err != nil {
				t.Fatalf("Request gagal: %v", err)
			}

			body, _ := io.ReadAll(resp.Body)
			bodyStr := string(body)

			if !strings.Contains(bodyStr, tc.expectedText) {
				t.Errorf("Ekspektasi render memuat %q, didapat: %s", tc.expectedText, bodyStr)
			}
		})
	}
}

Pertimbangan Edge Cases dan Caching

  • Initial Request (First Load): Pada request pertama (cold visit), browser belum membaca response header Accept-CH. Klien dapat menginjeksi cookie tz menggunakan snippet JavaScript kecil di <head> sebelum routing framework dijalankan, atau server fallback ke format netral ISO-8601 UTC dengan tag suppressHydrationWarning jika menggunakan React.
  • Reverse Proxy Caching: Header respons Vary: Sec-CH-Timezone wajib dikirim. Tanpa header ini, CDN (Cloudflare/Fastly) akan meng-cache HTML dengan timezone user A dan menyajikannya ke user B di belahan dunia berbeda.
  • Invalid Timezone String: Gunakan pengecekan error dari time.LoadLocation. Jika klien mengirim nilai manipulatif (misal Sec-CH-Timezone: Invalid/Location), sistem harus mengabaikan dan menggunakan default UTC secara graceful tanpa panic.