Uncategorized

How to Configure Nginx as a Reverse Proxy for a Node.js App

If your Node.js app is still answering requests directly on port 3000, you are missing TLS termination, static file caching, request buffering, rate limiting and a clean way to run several apps on one machine. An Nginx reverse proxy for Node.js solves all of that in about twenty lines of configuration. This tutorial is not a copy of the usual three-line proxy_pass snippet. You get a complete production server block, an explanation of every directive, correct WebSocket upgrade handling, SSL termination, and the three errors that break 90% of setups (502 Bad Gateway, wrong Host headers, port conflicts) with the exact commands to diagnose them. Why put Nginx in front of Node.js at all? Node.js is single-threaded per process and excellent at application logic, but it is not the best tool for the jobs a mature web server has done for twenty years. Nginx acts as the traffic cop in front of your process: SSL/TLS termination: certificates, HTTP/2 and HSTS live in Nginx, not in your JavaScript code. Static assets: images, CSS and bundles are served from disk by Nginx, without waking up the event loop. Port 80/443 binding: your Node process runs unprivileged on a high port on localhost. Slow client protection: Nginx buffers slow requests and responses so a bad mobile connection does not tie up a Node handler. Multiple apps, one IP: virtual hosts route api.example.com and app.example.com to different ports. Zero-downtime deploys and load balancing: an upstream block with several Node instances or a blue/green swap. Performance note: the extra hop costs a fraction of a millisecond on loopback. In exchange you get keepalive pooling, gzip/brotli, caching and connection buffering, which is almost always a net win on real traffic. What you need before you start A Linux server (Ubuntu 24.04/26.04 LTS, Debian 12/13, Rocky/Alma 9 or similar) with root or sudo access. Node.js 22 LTS or 24 LTS installed and an app that listens on a local port. Nginx 1.25 or newer (needed for the modern http2 on; directive). A domain name with an A/AAAA record pointing to the server, if you want HTTPS. Ports 80 and 443 open in your firewall or cloud security group. Step 1: make your Node.js app listen on localhost only The single most important line in your app is the bind address. Listening on 0.0.0.0 exposes port 3000 to the whole internet and lets people bypass Nginx entirely. // server.js const express = require(‘express’); const app = express(); // Trust the proxy so req.ip and req.protocol come from X-Forwarded-* headers app.set(‘trust proxy’, ‘loopback’); app.get(‘/’, (req, res) => { res.json({ ip: req.ip, proto: req.protocol, host: req.hostname }); }); const PORT = process.env.PORT || 3000; app.listen(PORT, ‘127.0.0.1’, () => { console.log(`Listening on http://127.0.0.1:${PORT}`); }); Two things matter here: app.listen(PORT, ‘127.0.0.1’) binds to loopback, so only Nginx can reach it. app.set(‘trust proxy’, ‘loopback’) tells Express to read X-Forwarded-For and X-Forwarded-Proto. Without it, every visitor looks like 127.0.0.1 and req.secure is always false, which breaks secure cookies and redirect logic. Quick check: curl -i http://127.0.0.1:3000/ If that does not return 200 from the server itself, Nginx will never work. Fix the app first. Step 1b: keep the process alive with systemd A reverse proxy is useless if the backend dies on logout. Create /etc/systemd/system/nodeapp.service: [Unit] Description=Node.js app behind Nginx After=network.target [Service] Type=simple User=nodeapp WorkingDirectory=/var/www/app Environment=NODE_ENV=production Environment=PORT=3000 ExecStart=/usr/bin/node server.js Restart=always RestartSec=3 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target sudo systemctl daemon-reload sudo systemctl enable –now nodeapp sudo systemctl status nodeapp PM2 works too, but systemd needs no extra dependency and integrates with journalctl -u nodeapp -f for logs. Step 2: install Nginx Debian and Ubuntu sudo apt update sudo apt install nginx -y sudo systemctl enable –now nginx nginx -v Rocky Linux, AlmaLinux, RHEL sudo dnf install nginx -y sudo systemctl enable –now nginx sudo firewall-cmd –permanent –add-service=http –add-service=https sudo firewall-cmd –reload Visit http://your-server-ip/. The default Nginx welcome page confirms the install. Config files live in: Distribution Where to put your server block Enable it Debian / Ubuntu /etc/nginx/sites-available/app.conf symlink into sites-enabled/ RHEL family /etc/nginx/conf.d/app.conf loaded automatically Step 3: the WebSocket upgrade map (do this first) Create /etc/nginx/conf.d/upgrade.conf so the map is defined once in the http context and reusable by every site: map $http_upgrade $connection_upgrade { default upgrade; ” “”; } Important detail most tutorials get wrong: the common snippet maps the empty value to close. That works, but it also closes the connection to your Node backend on every normal HTTP request, which disables upstream keepalive. Mapping to an empty string clears the Connection header instead, so plain requests reuse pooled connections and WebSocket requests still get Connection: upgrade. Step 4: the production-ready Nginx reverse proxy config This is the file you can copy. Replace app.example.com, the port, and the static path. upstream node_app { server 127.0.0.1:3000 max_fails=3 fail_timeout=10s; keepalive 64; } # HTTP: redirect everything to HTTPS server { listen 80; listen [::]:80; server_name app.example.com; location /.well-known/acme-challenge/ { root /var/www/html; } location / { return 301 https://$host$request_uri; } } # HTTPS: SSL termination + reverse proxy server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name app.example.com; ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets off; add_header Strict-Transport-Security “max-age=31536000; includeSubDomains” always; add_header X-Content-Type-Options nosniff always; server_tokens off; client_max_body_size 25m; client_body_timeout 30s; access_log /var/log/nginx/app.access.log; error_log /var/log/nginx/app.error.log warn; gzip on; gzip_comp_level 5; gzip_min_length 1024; gzip_proxied any; gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml; # Static files straight from disk, never through Node location /static/ { alias /var/www/app/public/; access_log off; expires 30d; add_header Cache-Control “public, immutable”; try_files $uri =404; } # Long-lived WebSocket endpoint location /socket.io/ { proxy_pass http://node_app; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } # Everything else goes to Node.js location / { proxy_pass http://node_app; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_connect_timeout 5s; proxy_send_timeout 60s; proxy_read_timeout 60s;

How to Configure Nginx as a Reverse Proxy for a Node.js App Read More »

How to Fix Cumulative Layout Shift Issues on Mobile Devices

If you’ve opened Chrome DevTools on a mobile emulator and watched your CLS score balloon past 0.25, you already know that mobile layout shifts are a different beast than desktop ones. Narrow viewports, slower connections, and injected third-party content combine to make Cumulative Layout Shift the trickiest Core Web Vital to tame on phones. This guide skips the generic advice and walks you through the actual causes we see on real mobile pages, with copy-paste fixes for each one. If you’re troubleshooting a specific issue, jump straight to the section that matches your problem. Quick Diagnosis: Where Is Your Mobile CLS Coming From? Before touching any code, identify the shifting element. In Chrome DevTools: Open the Performance panel and toggle mobile emulation (Slow 4G, 4x CPU throttling). Record a page load and look for the Layout Shifts track in red. Click any shift block to see the exact elements that moved and their shift score. You can also use the Web Vitals extension or run this snippet in the console to log shifts in real time: new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (!entry.hadRecentInput) { console.log(‘Shift:’, entry.value, entry.sources); } } }).observe({type: ‘layout-shift’, buffered: true}); Once you know which element is shifting, match it against the causes below. 1. Unsized Images and Videos This is still the number one cause of mobile CLS in 2026. When the browser doesn’t know an image’s dimensions, it renders zero height until the file downloads, then pushes everything below it downward. The Fix: Always Set Width and Height Modern browsers use the width and height attributes to calculate an aspect ratio and reserve space before the image loads: <img src=”hero-mobile.webp” width=”800″ height=”600″ alt=”Hero image” style=”max-width: 100%; height: auto;”> The inline style keeps the image responsive while the attributes preserve the aspect ratio. Do not omit the attributes and rely on CSS alone. For Responsive Art Direction If you serve different images per breakpoint, use CSS aspect-ratio to lock the box: .hero-image { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } For Videos and Iframes <div style=”aspect-ratio: 16/9; width: 100%;”> <iframe src=”https://player.example.com/video” style=”width:100%; height:100%; border:0;”></iframe> </div> 2. Web Fonts Causing FOUT and FOIT On mobile, custom fonts often load after first paint. When the swap happens, line heights and character widths change, shifting entire paragraphs. This is especially painful with heading fonts. The Fix: Preload and Use size-adjust Step 1: preload your critical font files. <link rel=”preload” href=”/fonts/inter-var.woff2″ as=”font” type=”font/woff2″ crossorigin> Step 2: use font-display: swap combined with a fallback that matches your web font’s metrics via size-adjust, ascent-override, and descent-override: @font-face { font-family: ‘Inter’; src: url(‘/fonts/inter-var.woff2’) format(‘woff2-variations’); font-display: swap; } @font-face { font-family: ‘Inter-fallback’; src: local(‘Arial’); size-adjust: 107%; ascent-override: 90%; descent-override: 22%; } body { font-family: ‘Inter’, ‘Inter-fallback’, sans-serif; } Tools like the Fontsource metric override generator can calculate the exact percentages for your custom font. 3. Injected Ads, Embeds, and Third-Party Widgets Ad slots that resize after loading are catastrophic on mobile because the viewport is narrow, so any injected banner pushes a huge percentage of visible content. Same story for cookie banners, chat widgets, and social embeds. The Fix: Reserve Space With Min-Height Always reserve the maximum expected size for the ad slot: .ad-slot-mobile { min-height: 250px; width: 100%; display: flex; align-items: center; justify-content: center; background: #f5f5f5; } If the ad ends up smaller, you’ll have empty space, but no shift. That’s the trade-off Google rewards. Cookie Banners: Overlay, Don’t Push Never insert a cookie banner at the top of the DOM. Use position: fixed at the bottom of the viewport: .cookie-banner { position: fixed; bottom: 0; left: 0; right: 0; z-index: 9999; } 4. Dynamically Injected Content Above the Fold Notifications, promo bars, “free shipping” strips, or A/B test variants that inject after hydration push everything downward. On mobile this often triggers shifts of 0.15 or more from a single element. The Fix: Server-Side Render or Reserve Space If the promo bar is conditional, decide server-side and render the placeholder either way: <div class=”promo-slot” style=”min-height: 40px;”> <!– Injected client-side, but space is reserved –> </div> For A/B tests, use CSS variants instead of DOM injection whenever possible. If you must inject, do it before first paint using a synchronous script in the <head>. 5. Hamburger Menus and Mobile Navigation A common mobile-specific issue: the mobile menu is rendered as a full-width block by default, then JavaScript kicks in and hides it. Between DOM ready and JS execution, the menu occupies space and pushes the hero down. The Fix: Hide With CSS, Not JavaScript @media (max-width: 768px) { .mobile-menu { display: none; } .mobile-menu.is-open { display: block; } } The menu is hidden immediately by the CSS parser, before JavaScript executes. 6. Lazy-Loaded Content Without Placeholders Native lazy loading (loading=”lazy”) is great, but only if dimensions are set. For components lazy-loaded by JavaScript (product cards, comment sections, related posts), you need skeleton placeholders. .product-card-skeleton { min-height: 320px; background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; } Mobile CLS Causes and Fixes Summary Cause Primary Fix Impact Unsized images width/height attributes + aspect-ratio High Web fonts Preload + size-adjust fallback Medium Ads and embeds min-height on slot container High Dynamic content injection SSR or reserved placeholder High Hamburger menu flash Hide via CSS media query Medium Lazy-loaded components Skeleton placeholders Medium Testing Your Fixes on Real Mobile Conditions After applying fixes, don’t just check Lighthouse scores. Verify in these three places: PageSpeed Insights: check the Mobile tab specifically, and look at field data (CrUX) not just lab data. Chrome DevTools: throttle to Slow 4G and 4x CPU, then reload with cache disabled. Search Console Core Web Vitals report: this is what Google actually uses for ranking. Give it 28 days to reflect changes. Aim for a mobile CLS score below 0.1. Between 0.1 and 0.25 needs improvement, and above 0.25 is considered poor. FAQ What is a good CLS score on mobile in 2026? The threshold remains 0.1 or lower for

How to Fix Cumulative Layout Shift Issues on Mobile Devices Read More »

How to Implement JWT Authentication in Express.js: A Complete Tutorial

If you’re building a modern API with Express.js, securing your endpoints is non-negotiable. JWT (JSON Web Token) authentication has become the industry standard for stateless authentication in Node.js applications, and for good reason: it’s lightweight, scalable, and works beautifully with single-page apps and mobile clients. In this tutorial, we’ll walk through a production-ready implementation of JWT authentication in Express.js, covering everything from token generation to refresh token rotation. No fluff, just working code with security best practices baked in. What is JWT and Why Use It in Express.js? A JSON Web Token is a compact, URL-safe string composed of three parts: a header, a payload, and a signature. When a user logs in successfully, your server generates a signed token that the client stores and sends back with each subsequent request. The advantages over traditional session-based auth: Stateless: No need to store session data on the server Scalable: Works perfectly across distributed systems and microservices Cross-domain friendly: Ideal for SPAs, mobile apps, and third-party APIs Self-contained: The token carries the user identity and claims Project Setup Let’s start by setting up a fresh Express.js project. Open your terminal and run: mkdir express-jwt-auth cd express-jwt-auth npm init -y npm install express jsonwebtoken bcrypt dotenv cookie-parser npm install –save-dev nodemon Required Dependencies Package Purpose express Web framework jsonwebtoken Sign and verify JWTs bcrypt Hash user passwords securely dotenv Manage environment variables cookie-parser Parse refresh token cookies Creating the .env File Never hardcode secrets. Create a .env file at the root of your project: PORT=3000 ACCESS_TOKEN_SECRET=your_super_long_random_access_secret_here REFRESH_TOKEN_SECRET=your_super_long_random_refresh_secret_here ACCESS_TOKEN_EXPIRY=15m REFRESH_TOKEN_EXPIRY=7d Pro tip: Generate strong secrets using node -e “console.log(require(‘crypto’).randomBytes(64).toString(‘hex’))”. Step 1: Building the Express Server Create a file called server.js: require(‘dotenv’).config(); const express = require(‘express’); const cookieParser = require(‘cookie-parser’); const authRoutes = require(‘./routes/auth’); const protectedRoutes = require(‘./routes/protected’); const app = express(); app.use(express.json()); app.use(cookieParser()); app.use(‘/api/auth’, authRoutes); app.use(‘/api’, protectedRoutes); const PORT = process.env.PORT || 3000; app.listen(PORT, () => console.log(`Server running on port ${PORT}`)); Step 2: Generating Access and Refresh Tokens A robust JWT setup uses two tokens: Access token: Short-lived (15 minutes), sent with every request Refresh token: Long-lived (7 days), used only to get a new access token Create a utils/tokens.js file: const jwt = require(‘jsonwebtoken’); const generateAccessToken = (user) => { return jwt.sign( { id: user.id, email: user.email }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: process.env.ACCESS_TOKEN_EXPIRY } ); }; const generateRefreshToken = (user) => { return jwt.sign( { id: user.id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: process.env.REFRESH_TOKEN_EXPIRY } ); }; module.exports = { generateAccessToken, generateRefreshToken }; Step 3: Building the Auth Routes Create routes/auth.js. For brevity, we’ll use an in-memory user store, but you’d typically use MongoDB, PostgreSQL, or another database. const express = require(‘express’); const bcrypt = require(‘bcrypt’); const jwt = require(‘jsonwebtoken’); const { generateAccessToken, generateRefreshToken } = require(‘../utils/tokens’); const router = express.Router(); const users = []; let refreshTokens = []; // Register router.post(‘/register’, async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ message: ‘Email and password required’ }); } const existing = users.find(u => u.email === email); if (existing) return res.status(409).json({ message: ‘User already exists’ }); const hashedPassword = await bcrypt.hash(password, 12); const user = { id: Date.now().toString(), email, password: hashedPassword }; users.push(user); res.status(201).json({ message: ‘User registered successfully’ }); } catch (err) { res.status(500).json({ message: ‘Server error’ }); } }); // Login router.post(‘/login’, async (req, res) => { const { email, password } = req.body; const user = users.find(u => u.email === email); if (!user) return res.status(401).json({ message: ‘Invalid credentials’ }); const valid = await bcrypt.compare(password, user.password); if (!valid) return res.status(401).json({ message: ‘Invalid credentials’ }); const accessToken = generateAccessToken(user); const refreshToken = generateRefreshToken(user); refreshTokens.push(refreshToken); res.cookie(‘refreshToken’, refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === ‘production’, sameSite: ‘strict’, maxAge: 7 * 24 * 60 * 60 * 1000 }); res.json({ accessToken }); }); // Refresh router.post(‘/refresh’, (req, res) => { const token = req.cookies.refreshToken; if (!token) return res.status(401).json({ message: ‘No refresh token’ }); if (!refreshTokens.includes(token)) return res.status(403).json({ message: ‘Invalid token’ }); jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, (err, decoded) => { if (err) return res.status(403).json({ message: ‘Token expired or invalid’ }); const user = users.find(u => u.id === decoded.id); if (!user) return res.status(404).json({ message: ‘User not found’ }); const newAccessToken = generateAccessToken(user); res.json({ accessToken: newAccessToken }); }); }); // Logout router.post(‘/logout’, (req, res) => { const token = req.cookies.refreshToken; refreshTokens = refreshTokens.filter(t => t !== token); res.clearCookie(‘refreshToken’); res.json({ message: ‘Logged out successfully’ }); }); module.exports = router; Step 4: Creating JWT Verification Middleware This middleware will protect any route you attach it to. Create middleware/authenticate.js: const jwt = require(‘jsonwebtoken’); const authenticateToken = (req, res, next) => { const authHeader = req.headers[‘authorization’]; const token = authHeader && authHeader.split(‘ ‘)[1]; if (!token) { return res.status(401).json({ message: ‘Access token required’ }); } jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, decoded) => { if (err) { const message = err.name === ‘TokenExpiredError’ ? ‘Token expired’ : ‘Invalid token’; return res.status(403).json({ message }); } req.user = decoded; next(); }); }; module.exports = authenticateToken; Step 5: Protecting Routes Create routes/protected.js to test our middleware: const express = require(‘express’); const authenticateToken = require(‘../middleware/authenticate’); const router = express.Router(); router.get(‘/profile’, authenticateToken, (req, res) => { res.json({ message: ‘Access granted’, user: req.user }); }); module.exports = router; Testing Your JWT Authentication Flow Start the server with node server.js and test using curl, Postman, or any HTTP client: Register: POST to /api/auth/register with email and password Login: POST to /api/auth/login, receive an access token Access protected route: GET /api/profile with header Authorization: Bearer YOUR_TOKEN Refresh: POST to /api/auth/refresh when the access token expires Logout: POST to /api/auth/logout to invalidate the refresh token Security Best Practices for JWT in Express.js Implementing JWT correctly is more than just generating tokens. Follow these rules in production: Keep access tokens short-lived: 15 minutes is a healthy default Store refresh tokens in HttpOnly cookies: Prevents XSS-based theft Use HTTPS everywhere: Set the secure cookie flag in production Rotate refresh tokens: Issue a new refresh token each time one is used Maintain a token blacklist or database whitelist: For revocation on logout or compromise Use strong secrets: At least 64 random bytes,

How to Implement JWT Authentication in Express.js: A Complete Tutorial Read More »

How to Lazy Load Images Without JavaScript Using the Loading Attribute

For years, developers relied on heavy JavaScript libraries like LazyLoad.js or lozad.js to defer offscreen images. Those days are over. With the native loading=”lazy” attribute, you can lazy load images without JavaScript in a single line of HTML. In this practical tutorial, we will show you exactly how it works, what browser support looks like in 2026, how to handle edge cases, and what kind of Lighthouse score boost you can realistically expect. Why Lazy Loading Images Matters Images are typically the heaviest assets on a web page. Loading every image upfront, even those far below the fold, wastes bandwidth, slows down Largest Contentful Paint (LCP), and hurts Core Web Vitals. Lazy loading defers offscreen images until the user scrolls near them, which results in: Faster initial page load Lower bandwidth consumption (huge win on mobile) Better Lighthouse Performance scores Improved SEO through better Core Web Vitals The One-Line Solution: loading=”lazy” Here is the entire implementation. No library, no script, no observer, no setup: <img src=”hero.jpg” alt=”Mountain landscape” loading=”lazy” width=”1200″ height=”800″> That is it. The browser handles everything natively. When the image is about to enter the viewport, the browser fetches it. When it is far away, the browser ignores it. The Three Possible Values Value Behavior lazy Defer loading until the image is near the viewport eager Load immediately (default behavior) auto Let the browser decide (not part of the spec, avoid) Browser Support in 2026 Native lazy loading is now supported by over 96% of global users. Every modern browser, including Chrome, Edge, Firefox, Safari (since version 15.4), Opera, and Samsung Internet, fully supports the attribute on both <img> and <iframe> elements. Browser Supported Since Chrome 76 Edge 79 Firefox 75 Safari 15.4 Opera 63 Browsers that do not understand the attribute simply ignore it and load the image normally. There is no breakage risk. Step-by-Step Tutorial Step 1: Identify Images to Lazy Load Apply loading=”lazy” only to images that appear below the fold. For your hero image and any image visible on first paint, use loading=”eager” or omit the attribute entirely. Step 2: Always Set width and height This is the most overlooked rule. Without explicit dimensions, the browser cannot reserve space for the image, which causes Cumulative Layout Shift (CLS). Always include them: <img src=”product.webp” alt=”Blue running shoe” loading=”lazy” width=”600″ height=”400″> Step 3: Combine with Modern Formats and srcset Native lazy loading works perfectly with responsive images: <img src=”photo-800.webp” srcset=”photo-400.webp 400w, photo-800.webp 800w, photo-1600.webp 1600w” sizes=”(max-width: 600px) 400px, 800px” alt=”Sunset over the ocean” loading=”lazy” width=”800″ height=”533″> Step 4: Lazy Load Iframes Too The same attribute works on iframes, which is perfect for embedded YouTube videos or maps: <iframe src=”https://www.youtube.com/embed/xyz” loading=”lazy” width=”560″ height=”315″></iframe> Fallback Strategy for Legacy Browsers If you still need to support a niche set of older browsers (Internet Explorer, very old Safari versions), you can apply a progressive enhancement pattern: Use loading=”lazy” as the primary mechanism Detect support via feature detection Optionally fall back to a tiny IntersectionObserver polyfill if (‘loading’ in HTMLImageElement.prototype) { // Native lazy loading is supported, do nothing } else { // Load a tiny fallback library only when needed const script = document.createElement(‘script’); script.src = ‘/js/lazysizes.min.js’; document.body.appendChild(script); } For 95% of projects in 2026, this fallback is unnecessary. The native attribute alone is enough. Real Performance Gains Measured with Lighthouse We tested a typical content-heavy blog page with 28 images on a simulated mobile 4G connection. Here are the results before and after applying loading=”lazy”: Metric Before After Improvement Performance Score 62 94 +32 points LCP 3.8s 1.9s -50% Total Bytes Transferred 4.2 MB 1.1 MB -74% Time to Interactive 5.6s 2.4s -57% These are real, repeatable gains achieved with a single HTML attribute. No JavaScript bundle. No build step. Common Mistakes to Avoid Lazy loading the hero image. This delays LCP and hurts your score. The first visible image should always be eager. Forgetting width and height. Causes layout shift and ruins CLS metrics. Applying it to background images. The attribute only works on <img> and <iframe>. CSS background images need a different approach. Combining with custom JS lazy loaders. Pick one. Running both creates conflicts and hurts performance. What About CSS Background Images? The native attribute does not cover CSS backgrounds. If you need that behavior, the cleanest workaround is to convert decorative backgrounds into <img> elements positioned with CSS, or use the content-visibility: auto CSS property to defer offscreen rendering. .section { content-visibility: auto; contain-intrinsic-size: 800px 600px; } FAQ Is loading=”lazy” safe to use in production? Yes. With over 96% browser support and graceful degradation in unsupported browsers, it is production-ready and recommended by Google, MDN, and web.dev. Does it hurt SEO? No. Googlebot fully supports native lazy loading and renders pages with it correctly. Just make sure your images have proper alt attributes and dimensions. Should I use it on every image? No. Skip it for images visible on first paint, especially the LCP image. Apply it only to offscreen content. Can I still use a JavaScript library on top? You can, but you should not. The native attribute is faster, lighter, and integrates with the browser’s prioritization engine. Mixing both adds complexity without benefit. Does it work with picture and source elements? Yes. Place loading=”lazy” on the inner <img> tag inside a <picture> element. It will apply regardless of which <source> the browser selects. Conclusion Native lazy loading is one of the highest-impact, lowest-effort performance wins available to web developers today. By replacing JavaScript-based solutions with the simple loading=”lazy” attribute, you reduce bundle size, improve Core Web Vitals, and give users a faster experience. If you have not migrated yet, this is the easiest performance optimization you will do this quarter. At GeminiWeb, we apply native-first techniques like this across every project to deliver fast, accessible, and future-proof websites. Got a slow site? Let us audit it.

How to Lazy Load Images Without JavaScript Using the Loading Attribute Read More »

How to Fix CORS Error in Express.js: Causes and Solutions

How to Fix CORS Error in Express.js: Causes, Solutions & Code Examples If you have ever built an API with Express.js and tried to call it from a frontend running on a different domain or port, you have almost certainly run into the dreaded CORS error. The browser console flashes a red message like “Access to fetch at … has been blocked by CORS policy” and your request silently fails. The good news: CORS errors are predictable and fixable once you understand what is happening behind the scenes. In this guide published by the GeminiWeb team, we will walk you through every common cause of CORS issues in Express.js and give you clear, copy-paste code examples for each fix. Whether you are dealing with simple requests, preflight failures, credentials, or multiple allowed origins, this post has you covered. What Is CORS and Why Does the Error Happen? CORS stands for Cross-Origin Resource Sharing. It is a security mechanism enforced by web browsers. When your frontend (e.g., https://app.example.com) makes an HTTP request to a backend on a different origin (e.g., https://api.example.com), the browser checks specific HTTP response headers to decide whether the frontend is allowed to read the response. If those headers are missing or misconfigured on your Express.js server, the browser blocks the response and throws a CORS error. Important: the server still processes the request. CORS is a browser-side enforcement, not a server-side block. Key CORS Response Headers Header Purpose Access-Control-Allow-Origin Specifies which origin(s) can access the resource Access-Control-Allow-Methods Lists the HTTP methods (GET, POST, PUT, DELETE, etc.) allowed Access-Control-Allow-Headers Lists the custom headers the client is permitted to send Access-Control-Allow-Credentials Indicates whether cookies/auth headers are allowed Access-Control-Max-Age How long (in seconds) the browser can cache the preflight response Most Common Causes of CORS Errors in Express.js Before jumping to solutions, let’s identify the root causes. In our experience at GeminiWeb working on dozens of Node.js projects, these are the issues we see most often: No CORS headers set at all on the Express server. Misconfigured Access-Control-Allow-Origin (wrong value, missing protocol, or typo). Preflight (OPTIONS) requests not handled, causing PUT/PATCH/DELETE or custom-header requests to fail. Credentials mode enabled on the client but the server uses a wildcard (*) origin. Multiple or dynamic origins not supported by the configuration. Middleware order issues where CORS headers are set after the route handler runs or after an error is thrown. Reverse proxy or hosting platform stripping headers before they reach the browser. Let’s fix each one. Solution 1: Use the cors npm Package (Quickest Fix) The fastest way to fix CORS errors in Express.js is to install the official cors middleware package. Step-by-step Install the package: npm install cors Import and use it in your Express app: const express = require(‘express’); const cors = require(‘cors’); const app = express(); // Enable CORS for all origins app.use(cors()); app.get(‘/api/data’, (req, res) => { res.json({ message: ‘CORS is working!’ }); }); app.listen(3000, () => { console.log(‘Server running on port 3000’); }); This adds Access-Control-Allow-Origin: * to every response. It is great for development, but not recommended for production because it allows any website to read your API responses. Solution 2: Allow a Specific Origin For production, you should restrict access to trusted domains only. app.use(cors({ origin: ‘https://myapp.example.com’ })); Now only requests originating from https://myapp.example.com will receive the proper CORS headers. Requests from any other origin will be blocked by the browser. Common mistake: forgetting to include the protocol. myapp.example.com without https:// will not match and the error will persist. Solution 3: Allow Multiple Origins If your API serves several frontends (e.g., a marketing site and a dashboard), you need to dynamically set the origin header. const allowedOrigins = [ ‘https://myapp.example.com’, ‘https://dashboard.example.com’, ‘http://localhost:5173’ ]; app.use(cors({ origin: function (origin, callback) { // Allow requests with no origin (e.g., mobile apps, curl) if (!origin) return callback(null, true); if (allowedOrigins.includes(origin)) { return callback(null, true); } else { return callback(new Error(‘Not allowed by CORS’)); } } })); Pro tip: store your allowed origins in an environment variable so you can change them without redeploying. # .env CORS_ORIGINS=https://myapp.example.com,https://dashboard.example.com const allowedOrigins = process.env.CORS_ORIGINS.split(‘,’); Solution 4: Handle Preflight Requests Properly What is a preflight request? Before sending certain requests (e.g., PUT, PATCH, DELETE, or any request with custom headers like Authorization), the browser sends a preliminary OPTIONS request called a preflight. If your server does not respond to this OPTIONS request with the correct headers, the actual request never fires. Fix with the cors package If you are using app.use(cors()) before your routes, preflight is handled automatically. But if you applied CORS only to specific routes, you also need to handle OPTIONS: // Enable preflight for all routes app.options(‘*’, cors()); // Then apply cors to your specific route app.get(‘/api/data’, cors(), (req, res) => { res.json({ message: ‘Hello’ }); }); Fix without the cors package (manual headers) app.use((req, res, next) => { res.header(‘Access-Control-Allow-Origin’, ‘https://myapp.example.com’); res.header(‘Access-Control-Allow-Methods’, ‘GET, POST, PUT, DELETE, OPTIONS’); res.header(‘Access-Control-Allow-Headers’, ‘Content-Type, Authorization’); // Handle preflight if (req.method === ‘OPTIONS’) { return res.sendStatus(204); } next(); }); Returning a 204 No Content status for OPTIONS tells the browser “go ahead, the actual request is allowed.” Solution 5: Fix CORS When Using Credentials (Cookies / Auth Headers) If your frontend sends cookies or an Authorization header, you need two things: The client must set credentials: ‘include’ (Fetch API) or withCredentials: true (Axios). The server must set Access-Control-Allow-Credentials: true and must not use a wildcard * for the origin. Client-side (Axios example) axios.get(‘https://api.example.com/user’, { withCredentials: true }); Server-side app.use(cors({ origin: ‘https://myapp.example.com’, credentials: true })); If you use origin: ‘*’ together with credentials: true, the browser will reject the response. This is one of the most frequent mistakes we see. Solution 6: Expose Custom Response Headers By default, the browser only exposes a limited set of response headers to JavaScript. If your API returns a custom header (like X-Total-Count for pagination), you need to explicitly expose it: app.use(cors({ origin: ‘https://myapp.example.com’, exposedHeaders: [‘X-Total-Count’, ‘X-Request-Id’] })); Solution 7: Fix Middleware Order Issues Express processes middleware in the order it

How to Fix CORS Error in Express.js: Causes and Solutions Read More »

How to Fix Bad Kerning in Logos: A Step-by-Step Guide

Why Kerning Can Make or Break Your Logo You have spent hours choosing the perfect typeface for a logo. The colors are right, the concept is strong, and the overall layout looks great. But something still feels off. The letters look awkward, cramped in some places and too loose in others. The problem? Bad kerning. Kerning is the adjustment of horizontal spacing between two individual letters. It is one of the most overlooked details in logo design, yet it is one of the most important. Poor kerning makes a logo look amateurish. Proper kerning makes it look polished, balanced, and unmistakably professional. In this guide, we will walk you through exactly how to kern a logo step by step. You will learn how to spot common kerning problems, fix them in popular design software, and develop an eye for letter spacing that separates amateur work from expert-level design. What Is Kerning, Exactly? Before we dive into the practical steps, let us make sure the definition is crystal clear. Kerning is the process of adjusting the space between two specific letters in a word. It is not the same as tracking (which adjusts spacing uniformly across an entire word or block of text) or leading (which controls vertical line spacing). Every letter has a unique shape. Some letters, like “A” and “V,” naturally create awkward gaps when placed next to each other. Others, like “H” and “I,” tend to sit more evenly. Kerning addresses those uneven gaps on a pair-by-pair basis. Kerning vs. Tracking vs. Letter Spacing: A Quick Comparison Term What It Adjusts When to Use It Kerning Space between two specific letters Logo design, headlines, display type Tracking Uniform spacing across a whole word or line Body text, stylistic all-caps treatments Letter Spacing (CSS) Similar to tracking, applied in web/code Web design, CSS styling For logo work, manual kerning is essential. Auto-kerning settings in design software get you part of the way there, but they almost never produce perfect results for display-size typography like logos. How to Spot Bad Kerning in a Logo The first step in learning how to kern a logo is training your eye to recognize problems. Here are the most common signs of poor kerning: Uneven “rivers” of space: Some letter pairs have wide gaps while others are tightly packed. Letters that appear to touch or collide: Certain combinations look like they are merging into one shape. Words that read as two separate words: A large gap in the middle of a word can split it visually. An overall “wobbly” feeling: The word does not look balanced even though you cannot immediately pinpoint why. Problematic Letter Combinations to Watch For Some letter pairings are notorious for causing kerning headaches. Keep a close eye on these combinations: AV, AW, AT, AY – The diagonal and horizontal shapes create large triangular gaps. RA, PA, FA – The arm or crossbar of the first letter creates space above the “A.” To, Tr, Ta – The overhang of the “T” leaves excessive room next to lowercase letters. LT, LY, LA – The open right side of “L” creates visible holes. WA, VA, Yo – Diagonal strokes paired with round or angled letters. ry, ly, ty – Lowercase combinations that often need tightening. If your logo contains any of these pairs, you will almost certainly need to adjust the kerning manually. How to Kern a Logo: Step-by-Step Process Now let us get into the practical workflow. This process works regardless of which design tool you use, whether that is Adobe Illustrator, Figma, Affinity Designer, or another application. Step 1: Type Out Your Logo Text and Choose Your Font Start by setting your logo text in the typeface you have selected. Use a large point size so that spacing issues are easier to see. Working at 150pt or larger on screen is a good starting point. At this stage, leave the kerning on the default “Auto” or “Metrics” setting. This gives you the font designer’s built-in kerning as your baseline. Step 2: Switch to Optical Kerning (Optional Starting Point) Most professional design tools offer an “Optical” kerning mode that calculates spacing based on the actual shapes of the letters rather than the font’s built-in kerning table. Try both Metrics and Optical settings and see which one gives you a better starting point. For many display fonts, Optical kerning produces a more even result. But neither setting will be perfect for logo work, so manual adjustment is always the next step. Step 3: Kern in Groups of Three Letters This is one of the most effective techniques professional typographers use. Instead of trying to evaluate an entire word at once, break it down into groups of three consecutive letters. Here is how it works: Look at the first three letters of your logo text. Focus only on those three. Adjust the spacing between the first and second letter until the gap looks visually equal to the gap between the second and third letter. Move forward by one letter. Now look at letters 2, 3, and 4. Repeat the process. Continue until you reach the end of the word. Go back to the beginning and repeat the entire process one or two more times. The goal is not to make every gap exactly the same number of pixels. The goal is to make every gap feel visually equal. Because letters have different shapes (round, straight, diagonal, open), the actual measured distances will vary. What matters is the perceived balance of space. Step 4: Use the Squint Test Once you have completed your initial kerning pass, squint your eyes or step back from your screen. When the letters blur slightly, uneven spacing becomes much more obvious. You will see dark clumps where letters are too tight and bright holes where they are too loose. This simple technique is used by professional designers every day. It works because squinting removes your ability to read the actual letters, forcing your brain to evaluate the rhythm

How to Fix Bad Kerning in Logos: A Step-by-Step Guide Read More »

Company Name

Gemini Web

Company Address

3444 Hall Valley Drive, Davy, WV 24828 USA

Company Email

[email protected]

Copyright © 2022 Gemini Web. All Rights Reserved.