File-Based Blog System with Hot Reload
The blog you're reading was published by dropping a markdown file into a
directory. No database writes, no restarts, no deploy. A background goroutine
discovers new and modified files, parses frontmatter, renders markdown to HTML,
and swaps the cache — all while readers keep getting served without
interruption. The metrics system and the blog cache both solve the same
one-writer-many-readers problem, but with completely different primitives. This
post covers how the blog system works end to end, and why sync.RWMutex is the
right answer here when atomic.Pointer was the right answer for metrics.
The Goal: Drop a File, See It Live
Whole frameworks are designed around CMS. They are complicated, opinionated, and very good at what they do at scale. This site is not at scale, it's just for me. I have multiple ways I manage my files and prefer keeping all of my data in very readable plain text. I've written Markdown notes for years and it's a pattern I want to continue on my blog.
File Discovery: Walking the Content Directory
Starting basic, I set up a goroutine to scan a static directory for all files. However, we don't want to full scan every single time we refresh. While the scale on my site is incredibly low, markdown and html rendering can be expensive. Rather than full rerender, I keep metadata in memory to detect when files have been saved or deleted. With that data, I can make sure I am only touching what could potentially have changed.
func (d *discoverer) Scan() (map[string][]byte, []string) {
dir, err := os.ReadDir(d.RootPath)
if err != nil {
log.Panic(err)
}
changed := make(map[string][]byte)
deleted := make([]string, 0)
seen := make(map[string]bool)
for _, file := range dir {
// Skip templates
if file.Name()[0] == '_' {
continue
}
if file.IsDir() {
continue
}
info, err := file.Info()
if err != nil {
log.Printf("Err reading file info: %v\n", err)
continue
}
modTime, ok := d.modTime[file.Name()]
seen[file.Name()] = true
if !ok || modTime.Before(info.ModTime()) {
data, err := os.ReadFile(filepath.Join(d.RootPath, file.Name()))
if err != nil {
log.Printf("Err reading file: %v\n", err)
continue
}
d.modTime[file.Name()] = info.ModTime()
changed[file.Name()] = data
}
}
for k := range d.modTime {
ok := seen[k]
if !ok {
deleted = append(deleted, k)
delete(d.modTime, k)
}
}
return changed, deleted
}
That's it. Now there are os specific changes I can make to get rid of the need to poll at all, but that's something we can look into at a different time. For now, this works great at this scale. We read all new and updated files in at this time. A typical file may look something like this:
---
title: "File-Based Blog System with Hot Reload"
slug: "file-based-blog-hot-reload"
description: "Hot reloading files and blogs"
publish_date: 2026-02-09
status: "draft"
tags: ["go", "architecture", "concurrency", "blog"]
---
The blog you're reading was published by dropping a markdown file into a
directory. No database writes, no restarts, no deploy. A background goroutine...
Parsing Frontmatter and Rendering Markdown
Frontmatter is an important concept for these blog files. They are what control
the SEO metadata, blog title, path, tags, and publishing. I store them in a yaml
format -- it feels much more natural than json or self parsing the data. Using the
gopkg.in/yaml.v3 package makes parsing this data trivial, just like the json
unmarshaler.
type Post struct {
Slug string
Title string
Date time.Time `yaml:"publish_date"`
Tags []string
Description string
Status string
Content template.HTML
}
func parse(rawPost []byte) (Post, []byte, error) {
post := Post{}
after, found := bytes.CutPrefix(rawPost, []byte("---"))
if !found {
return post, nil, fmt.Errorf("Malformed blog: Missing prefix")
}
frontmatter, content, found := bytes.Cut(after, []byte("---"))
if !found {
return post, nil, fmt.Errorf("Malformed blog: Missing frontmatter")
}
err := yaml.Unmarshal(frontmatter, &post)
if err != nil {
return post, nil, err
}
return post, content, nil
}
I used bytes.CutPrefix and bytes.Cut to make sure my files conform to my expectations, return before/after data without resulting in awkward slices that split may have offered.
Another important piece of the frontmatter is it allows me to do some rudimentary
scheduling. While this is outside the scope of the blog store, I feel it is important
to point out that nothing is stopping me from setting the publish_date to 40
years in the future. At this point, we know what is frontmatter, and what is
markdown waiting to be rendered.
Now, could I write a markdown parser and renderer by myself? Sure. Is that what I did?
Absolutely not. Thank you to github.com/yuin/goldmark for allowing me to have a
simple render wrapper.
func render(data []byte) (template.HTML, error) {
var buf bytes.Buffer
if err := goldmark.Convert(data, &buf); err != nil {
return "", err
}
content := template.HTML(buf.String())
return content, nil
}
Background Goroutine: The Refresh Loop
From here, we have everything we need to create our data store on load. We take the output, pull the slugs off the frontmatter, and shove them all into a map for lookup and a list for discovery. This serves as our simple in memory store. It's lightning fast to build even with reading files, parsing, converting to html. Usually, this takes ~500 microseconds per blog modification, so starting up with 15 blogs takes about 7.5 ms. But how do we maintain this list during runtime? Enter the Refresh goroutine.
func NewStore(contentDir string, refreshRate time.Duration) *Store {
s := &Store {
discoverer: newDiscoverer(contentDir),
posts: make([]Post, 0),
postCache: make(map[string]Post),
slugByPath: make(map[string]string),
}
if refreshRate > 0 * time.Second {
go s.PeriodicRefresh(refreshRate)
}
return s
}
func (s *Store) PeriodicRefresh(interval time.Duration){
timer := time.NewTicker(interval)
s.Refresh()
for range timer.C {
s.Refresh()
}
}
func (s *Store) Refresh() {
changed, deleted := s.discoverer.Scan()
newSlugToPath := make(map[string]string)
postChanges := make([]Post, 0)
for path, content := range changed {
post, markdown, err := parse(content)
if err != nil {
log.Printf("Err parsing: %v\n", err)
continue
}
body, err := render(markdown)
if err != nil {
log.Printf("Error rendering: %v\n", err)
continue
}
post.Content = body
postChanges = append(postChanges, post)
newSlugToPath[post.Slug] = path
}
s.rwmux.Lock()
defer s.rwmux.Unlock()
s.updateFilesLocked(postChanges)
s.updateSlugsLocked(newSlugToPath)
s.deleteFilesLocked(deleted)
s.rebuildPostListLocked()
}
This orchestrates the whole process. We know we ALWAYS want to refresh, so we
make the startup when we initialize this process. Everything is internal to the
blogs package, so this NewStore is the only way to create this object. This
finds all file changes, parses the files, renders them, and then updates the store's
in-memory data structures. You may have noticed the rwmux.Lock() in there --
but why that lock and not something else?
One Writer, Many Readers — Again
Any number of users may read a blog at any given moment. If you read /blog/building-this-site, you may recognize this problem. We had the exact same issue with /metrics where thousands of people may be watching metrics and the naive approach was to run a metrics fetch and return that data to the view. While these problems of hundreds of readers against a single writer might seem similar, they couldn't be more different.
Why atomic.Pointer Was Right for Metrics
On /metrics, we had an incredibly expensive function call. We were calling this
function uniquely for every person connected to the metrics tab. This puts an ungodly
amount of pressure onto the server. Every call to runtime.ReadMemStats is a world stopping call for
go. We cannot afford 1000 concurrent world stopping calls. Enter the atomic.Pointer.
Similar to our Refresh function in our BlogStore, we startup a refresh function to
pull in a single object and refresh it every second. 1 world stopping call every second
is fine. We have a single object we can reference with the atomic pointer with
no locking. This is wonderful for this use case.
Why RWMutex Is Right for the Blog Cache
If you were paying attention to our data structures, we have 3 different stores
required to keep the store functional. We need the path -> slug list to detect
what blogs to delete when files disappear. We need the list for persistent ordering.
And obviously we need our map of slug -> post for our normal lookup. We could still
use an atomic.Pointer here by wrapping all three in a single struct and swapping
the whole thing, but there is a key downside: every refresh would require deep
copying all three maps into a new struct, even if only one post changed. That's
a lot of wasted allocation and CPU for what should be a surgical update.
Instead, we can rely on the RWMutex. This little beauty has two separate locking
functions, one that indicates it's time to write, and one that indicates it's time
to read. This allows many readers to access this data without blocking each other,
improving read throughput, but once we need to access and write the data, we start
to block new reads and wait until the readers give up their locks, then perform our
single millisecond updates, and release the write lock to allow readers through again.
More importantly, what this allows us to do is partial updates, keeping the work we do to an absolute minimum. And on top of all that, we do all of the processing outside of the lock, so we are holding the update for as little time as possible.
A simple blogging engine that just works
All in all, this system embodies my ethos for this site. In roughly 200 lines of code, I have a functional blog engine that does exactly what I need it to do. I drop a markdown file in, and within a minute it is live to the world with no problems. It gives a key example of how problems, especially concurrency problems, may look the same but require different techniques to solve. But this still leaves a constant goroutine up that pulses every 30s to check for updates. What if I could completely remove the need to poll? What if there was a way for the system to tell me that I need to refresh my index?