Diaries of a Modern Ninja nima@home:~$

Quick tips for a better website


1

These are some tips to improve your website, examples are for Jekyll as my website is based on it but the main tips are technology agnostic.


Table of contents


1) Use Cloudflare

  • Enable all useful page speed and performance options in Cloudflare panel

    Speed -> Settings: (In the Site Recommendations section): Enable/Disable according to the picture:


    Site Recommendations section in Cloudflare web panel


    Enable: HTTP/2, HTTP/3, HTTP/2 to Origin, Always use HTTPS, TLS 1.3, Early Hints

    Disable: All the rest, including 0-RTT Connection Resumption (this option can speed up the connection but it also makes it less secure by providing grounds for replay attacks so we disable it).


  • Less noticed but still very important: Enable TLS 1.2 in addition to TLS 1.3

    I was able to retrieve my website feeds again in Newsflow2 and some other RSS feeds clients after enabling TLS 1.2 again, these clients don’t show any errors but still don’t load your feeds if you enable only TLS 1.3.

    Use this setting in Cloudflare, SSL/TLS -> Edge Certificates:

    Minimum TLS version: TLS 1.2


    Minimum TLS version setting in Cloudflare web panel


    How I found it was TLS issue? I used RSS Validator3 for checking the validity of my website RSS feed after some changes and it returned TLS error, so I got suspicious that it might be due to only TLS 1.3 being enabled and I tested after enabling TLS 1.2 and voila! My RSS feed clients started working again!


  • Use Cloudflare’s Email Address Obfuscation

    Use it for your email address but use it only on one Contact page:

    Remove email address from website footer and only use it in a Contact page to limit the overhead of Cloudflare JS used for email address obfuscation to only one page.

    A more solid solution is to use a local JS for email address obfuscation, the local solution can be more complicated but still suffers from lack of randomization. The Cloudflare email obfuscation solution is good enough, enable it if you haven’t already.

    To enable Cloudflare email obfuscation:

    Scrape Shield -> Email Address Obfuscation

    While there you can also enable Hotlink Protection.


    Scrape Shield settings in Cloudflare web panel


There are many more Cloudflare features that can help us run a more fast, optimized and secure website. I may write a post in the future dedicated to the most useful Cloudflare settings for websites.


2) Reduce Cumulative Layout Shift(CLS)

  • Use hardcoded width and height for img and video tags

  • Use a CSS class to still support responsive after previous tip

    This is the way I handle it in my Jekyll website, in base.scss file:

    // Fix Layout Shift issue(combined with adding width and height to html img and video tags)
    img, video {
      max-width: 100%;  /* makes it responsive */
      height: auto;     /* keeps aspect ratio */
      display: block;   /* avoids inline spacing issues */
    }
    
    img.no-responsive, video.no-responsive {
      max-width: none;
      height: auto;
      display: inline;
    }
    



3) Reduce Largest Contentful Paint(LCP)

  • Use more modern media formats

    Use AVIF,WebP image formats instead of PNG or JPG and MP4,WebM video formats instead of heavy GIF files to significantly improve load speed and decrease file sizes.

    You can use FFmpeg4, cwebp5 and ImageMagick6 to convert PNG/JPG to more modern and web-friendly AVIF/WebP formats.

    You can also use FFmpeg to resize MP4 files or convert them to WebM format.

    Here is some example commands I use for image conversion that I’ve found to return desirable results for me in most cases:

    # for images
    ## mainly used
    ### convert png/jpg to webp/avif:
    cwebp -q 90 -near_lossless 95 -m 6 test.png -o test.webp
    magick test.png -quality 80 test.avif
    ### resize png/jpg, keep aspect ratio(example: use 130x for width: 130, use x130 for height: 130 ):
    magick why-we-sleep-book.jpg -resize 130x why-we-sleep-book-thumb.jpg
    
    ## some other variations that had good results for me
    ffmpeg -i test.png -c:v libaom-av1 -crf 20 -b:v 0 test.avif
    ffmpeg -i test.png -q:v 80 test.webp
    ffmpeg -i test.png -c:v libaom-av1 -crf 30 -b:v 0 test.avif
    
    # for videos
    ## resize video files
    ffmpeg -i test.mp4 -vf scale=256:256 test_resized.mp4
    ffmpeg -i test.mp4 -vf scale=308:462 -c:v libx264 -crf 32 -preset veryslow -an test_resized.mp4
    
    ## convert to webm
    ffmpeg -i test.mp4 -c:v libaom-av1 -crf 40 -b:v 0 -an test.webm
    
    ffmpeg -i test.mp4 -c:v libaom-av1 -crf 30 -b:v 0 -pass 1 -an -f null -y /dev/null
    ffmpeg -i test.mp4 -c:v libaom-av1 -crf 30 -b:v 0 -pass 2 -an test.webm
    

    In the case of AVIF and WebP use, 96-97% of viewers are supported, for those with older legacy browsers or devices, fallback to original PNG or JPG formats.

    I’ve written a Ruby plugin7 for my website to do just that and added more arguments for more control. It’s open-sourced, you can see my whole website in GitHub8.


  • Use fetchpriority="high"

    Use it for your largest contentful paint (LCP) image or video that is immediately visible above the fold.


  • Use loading attribute for img tag

    loading="lazy": Great for images below the fold, but never on your LCP image (causes delays).

    Rule of thumb:

    • Above the fold: loading="eager" (or omit, since eager is default).
    • Below the fold: loading="lazy".


  • Use decoding="async" for secondary images

    Usually improves main-thread responsiveness.

    For the LCP image, async decoding can slightly delay the first paint.

    Minimal risk overall, safe for secondary images.


  • Jekyll Example: This is how I use Preload for my hero poster images(hero images that are loaded before video content is fully downloaded) in the head.html file of my Jekyll website:

    {% if page.hero-poster %}<link rel="preload" as="image" href="{{ page.hero-poster }}" fetchpriority="high">{% endif %}
    

    and use hero-poster variable in the frontmatter and body of the pages that I want to use hero poster image on:

    ---
    layout: page
    title: Blog
    image: /blog/assets/robot1.png
    description: "Here I talk about anything, mostly technical topics."
    hero-poster: /blog/assets/robot1.avif
    ---
    
    <video autoplay muted loop playsinline width="308" height="462" poster="{{ page.hero-poster }}">
      <source src="/blog/assets/robot1.webm" type="video/webm">
      <source src="/blog/assets/robot1.mp4" type="video/mp4">
    </video>
    


  • Rule of thumb for safe optimization

    LCP/hero image: preload + fetchpriority=”high” + eager (no async).

    Other above-the-fold: optional async decoding, no lazy.

    Below-the-fold: lazy + async decoding.

    Don’t mark too many resources as high priority.

    Keep image sizes reasonable.


  • Use explicit width & height for images

    we already did that for reducing Cumulative Layout Shift in tip number 2.



4) Optimize CSS

  • Compress the CSS file and disable source maps

    Example: in a Jekyll website, in _config.yaml:

    sass:
      style: compressed
      sourcemap: never
    

    style: compressed: minifies your CSS (removes whitespace, comments, etc.).

    sourcemap: never: stops Jekyll from generating main.css.map and removes the reference.

    Result:

    • You’ll only get a small, minified main.css.

    • No .map file.

    • Leanest possible setup for GitHub Pages.

    • The result is a tiny, production-ready main.css that PageSpeed Insights sees as fully optimized.


  • Use PurgeCSS post-build to remove CSS codes that are not used

    If you build your website locally, create a workflow to run PurgeCSS9 after your website build to remove unused parts of your CSS file.


  • Inline critical CSS

    Use a tool like Critical10 CSS, Penthouse11, Critters12 or Crittr13 to extract critical parts of CSS that are used for above the fold content of each of your important HTML pages and inline those parts of CSS for faster page loads. Use nonce for inline styles.



5) Minify JS scripts

Minify inline and external JS scripts with a tool like Terser14. Also use nonce for inline scripts.



6) Some other tips

  • Load resources locally

    If you use some website profile badges on your website (like the TryHackMe and HackTheBox badges on my website footer), it’s better to download the image and serve it from your own website resources instead of direct requests for them. So that you can optimize these pictures and use smaller and more modern image formats and also prevent additional requests and cookies of third party services you send requests to.

    Another benefit is you don’t need to add additional CSP rules for external resources of your website.

    Example: I’ve added an option in my _config.yml file that gives me the option to choose to serve those pictures locally or from upstream servers:

    badges:
      hackthebox: "768488"
      selfhost_hackthebox: true
      tryhackme: "nima"
      selfhost_tryhackme: true
    

    also in footer.html:

      {% if site.badges.hackthebox %}
        <li class="social-media-list">
          {% if site.badges.selfhost_hackthebox %}
            <!-- local HTB badge -->
            <a title="Hack The Box Profile" href="https://app.hackthebox.com/profile/{{ site.badges.hackthebox }}">
              {% smart_image "/assets/{{ site.badges.hackthebox }}.png" 220 50 "Hack The Box Profile" lazy "" avif async "no-responsive" %}
            </a>
          {% else %}
            <!-- upstream HTB badge -->
            <a title="Hack The Box Profile" href="https://app.hackthebox.com/profile/{{ site.badges.hackthebox }}">
              <img src="https://www.hackthebox.eu/badge/image/{{ site.badges.hackthebox }}" width="220" height="50" loading="lazy" decoding="async" alt="Hack The Box" class="no-responsive">
            </a>
          {% endif %}
        </li>
      {% endif %}
    
      {% if site.badges.tryhackme %}
        <li class="social-media-list">
          {% if site.badges.selfhost_tryhackme %}
            <!-- local TryHackMe badge -->
            <a title="TryHackMe Profile" href="https://tryhackme.com/p/{{ site.badges.tryhackme }}">
              {% smart_image "/assets/{{ site.badges.tryhackme }}.png" 329 88 "TryHackMe Profile" lazy "" avif async "no-responsive" %}
            </a>
          {% else %}
            <!-- upstream TryHackMe badge -->
            <a title="TryHackMe Profile" href="https://tryhackme.com/p/{{ site.badges.tryhackme }}">
              <img src="https://tryhackme-badges.s3.amazonaws.com/{{ site.badges.tryhackme }}.png" width="329" height="88" loading="lazy" decoding="async" alt="TryHackMe Profile" class="no-responsive">
            </a>
          {% endif %}
        </li>
      {% endif %}
    


  • Create clean workflows for website build and publish

    You can have two branches, one for website build and another for publish.

    Example: I use main branch for build and gh-pages for publish.

    Here is my local build pipeline for main branch , fully automated:

    npm run build
    

    here’s the code for package.json:

    {
      "name": "nima.ninja",
      "version": "1.0.0",
      "type": "module",
      "scripts": {
        "build:jekyll:1": "bundle exec jekyll build",
    
        "postcss": "postcss \"_site/css/main.css\" -o \"_site/css/main.css\"",
    
        "copy:minified-css": "node copy-css.js \"_site/css/main.css\" \"css/temp.main.css\"",
    
        "postcss:critical": "postcss \"_site/css/main.css\" -o \"_site/css/main-critical.css\" --env critical",
    
        "critical:home:mobile": "critical _site/index.html --base=_site --css=_site/css/main-critical.css --width=412 --height=667 > _includes/critical-home-mobile.css || echo 'Skipped home mobile critical'",
        "critical:home:tablet": "critical _site/index.html --base=_site --css=_site/css/main-critical.css --width=768 --height=800 > _includes/critical-home-tablet.css || echo 'Skipped home tablet critical'",
        "critical:home:desktop": "critical _site/index.html --base=_site --css=_site/css/main-critical.css --width=1440 --height=800 > _includes/critical-home-desktop.css || echo 'Skipped home desktop critical'",
    
        "critical:post:mobile": "critical _site/blog/2025/quick-tips-for-a-better-website.html --base=_site --css=_site/css/main-critical.css --width=412 --height=667 > _includes/critical-post-mobile.css || echo 'Skipped post mobile critical'",
        "critical:post:tablet": "critical _site/blog/2025/quick-tips-for-a-better-website.html --base=_site --css=_site/css/main-critical.css --width=768 --height=800 > _includes/critical-post-tablet.css || echo 'Skipped post tablet critical'",
        "critical:post:desktop": "critical _site/blog/2025/quick-tips-for-a-better-website.html --base=_site --css=_site/css/main-critical.css --width=1440 --height=800 > _includes/critical-post-desktop.css || echo 'Skipped post desktop critical'",
    
        "critical:page:mobile": "critical _site/books/index.html --base=_site --css=_site/css/main-critical.css --width=412 --height=667 > _includes/critical-page-mobile.css || echo 'Skipped page mobile critical'",
        "critical:page:tablet": "critical _site/books/index.html --base=_site --css=_site/css/main-critical.css --width=768 --height=800 > _includes/critical-page-tablet.css || echo 'Skipped page tablet critical'",
        "critical:page:desktop": "critical _site/books/index.html --base=_site --css=_site/css/main-critical.css --width=1440 --height=800 > _includes/critical-page-desktop.css || echo 'Skipped page desktop critical'",
    
        "critical:home": "npm run critical:home:mobile && npm run critical:home:tablet && npm run critical:home:desktop",
        "critical:post": "npm run critical:post:mobile && npm run critical:post:tablet && npm run critical:post:desktop",
        "critical:page": "npm run critical:page:mobile && npm run critical:page:tablet && npm run critical:page:desktop",
        "critical": "npm run critical:home && npm run critical:post && npm run critical:page",
    
        "minify:inlinejs": "terser \"_includes/lazy-load-back-to-top.js\" -o \"_includes/lazy-load-back-to-top.min.js\" -c -m",
    
        "build:jekyll:2": "bundle exec jekyll build",
    
        "minify:js": "terser \"_site/js/back-to-top.js\" -o \"_site/js/back-to-top.js\" -c -m && terser \"_site/js/particles.js\" -o \"_site/js/particles.js\" -c -m",
    
        "copy:back": "node copy-css.js \"css/temp.main.css\" \"_site/css/main.css\"",
    
        "build": "npm run build:jekyll:1 && npm run postcss && npm run copy:minified-css && npm run postcss:critical && npm run critical && npm run minify:inlinejs && npm run build:jekyll:2 && npm run minify:js && npm run copy:back"
      },
      "scriptsComments": {
        "build:jekyll:1": "First Jekyll build: generate HTML pages.",
        "postcss": "Run PostCSS with PurgeCSS to remove unused CSS for live site.",
        "copy:minified-css": "Save a temp copy of main.css so it can be restored later.",
        "postcss:critical": "Run PostCSS again to generate main-critical.css, removing selectors that should not be inlined by Critical (like #toTopButton).",
        "critical:*": "Generate critical CSS for different breakpoints and pages, reading main-critical.css.",
        "minify:inlinejs": "Minify inline JS scripts",
        "build:jekyll:2": "Second Jekyll build: inject generated Critical CSS includes and generated inline JS scripts includes into HTML pages.",
        "minify:js": "Minify JS files.",
        "copy:back": "Restore the full PurgeCSS main.css to _site for live site.",
        "build": "Full build pipeline: build site, process CSS, generate Critical CSS, rebuild HTML with critical CSS and minified inline scripts, minify JS files, restore main CSS."
      },
      "devDependencies": {
        "@fullhuman/postcss-purgecss": "7.0.2",
        "critical": "7.2.1",
        "postcss": "8.5.6",
        "postcss-cli": "11.0.1",
        "terser": "5.44.0"
      }
    }
    

    The script comments are clear about each of those build lines.

    Codes for all of these files exist in my website GitHub repo.

    For gh-pages branch which is used for publishing website files for GitHub Pages:

    # 1️⃣ Prepare .gitignore in gh-pages
    git checkout gh-pages
    
    # Make sure .gitignore exists and includes only what you want ignored:
    _site/
    CNAME
    
    # Node modules
    node_modules/
    
    # Jekyll build cache
    .jekyll-cache/
    
    # Commit if needed:
    git add .gitignore
    git commit -m "Fix .gitignore for gh-pages"
    
    # 2️⃣ Clean branch root but keep .git, .gitignore, CNAME, node_modules
    Get-ChildItem -Force | Where-Object {
        $_.Name -notin @('.git', '.gitignore', 'node_modules', '_site')
    } | Remove-Item -Recurse -Force
    
    # Optional: delete temporary or cache files:
    if (Test-Path ".jekyll-cache") { Remove-Item -Recurse -Force .jekyll-cache }
    
    # 3️⃣ Copy fresh _site contents into repo root
    robocopy "_site" "." /E
    
    # 4️⃣ Stage all changes
    git add .
    
    # 5️⃣ Show what will be committed
    git status
    
    # 6️⃣ Commit & push automatically if there are changes
    if (-not (git diff --cached --quiet)) {
        git commit -m "Update site from _site"
        git push origin gh-pages
    } else {
        Write-Output "No changes to commit. Nothing to push."
    }
    

    The result: built website code is committed and pushed to the gh-pages branch of my website15 on GitHub.



References