diff --git a/package.json b/package.json
index c51a9ed..5c75701 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
     "docs": "npx quartz build --serve -d docs",
     "check": "tsc --noEmit && npx prettier . --check",
     "format": "npx prettier . --write",
-    "test": "tsx ./quartz/util/path.test.ts",
+    "test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts",
     "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
   },
   "engines": {
diff --git a/quartz/build.ts b/quartz/build.ts
index 1f90301..ed166bb 100644
--- a/quartz/build.ts
+++ b/quartz/build.ts
@@ -17,6 +17,10 @@ import { glob, toPosixPath } from "./util/glob"
 import { trace } from "./util/trace"
 import { options } from "./util/sourcemap"
 import { Mutex } from "async-mutex"
+import DepGraph from "./depgraph"
+import { getStaticResourcesFromPlugins } from "./plugins"
+
+type Dependencies = Record<string, DepGraph<FilePath> | null>
 
 type BuildData = {
   ctx: BuildCtx
@@ -29,8 +33,11 @@ type BuildData = {
   toRebuild: Set<FilePath>
   toRemove: Set<FilePath>
   lastBuildMs: number
+  dependencies: Dependencies
 }
 
+type FileEvent = "add" | "change" | "delete"
+
 async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
   const ctx: BuildCtx = {
     argv,
@@ -68,12 +75,24 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
 
   const parsedFiles = await parseMarkdown(ctx, filePaths)
   const filteredContent = filterContent(ctx, parsedFiles)
+
+  const dependencies: Record<string, DepGraph<FilePath> | null> = {}
+
+  // Only build dependency graphs if we're doing a fast rebuild
+  if (argv.fastRebuild) {
+    const staticResources = getStaticResourcesFromPlugins(ctx)
+    for (const emitter of cfg.plugins.emitters) {
+      dependencies[emitter.name] =
+        (await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null
+    }
+  }
+
   await emitContent(ctx, filteredContent)
   console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`))
   release()
 
   if (argv.serve) {
-    return startServing(ctx, mut, parsedFiles, clientRefresh)
+    return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies)
   }
 }
 
@@ -83,9 +102,11 @@ async function startServing(
   mut: Mutex,
   initialContent: ProcessedContent[],
   clientRefresh: () => void,
+  dependencies: Dependencies, // emitter name: dep graph
 ) {
   const { argv } = ctx
 
+  // cache file parse results
   const contentMap = new Map<FilePath, ProcessedContent>()
   for (const content of initialContent) {
     const [_tree, vfile] = content
@@ -95,6 +116,7 @@ async function startServing(
   const buildData: BuildData = {
     ctx,
     mut,
+    dependencies,
     contentMap,
     ignored: await isGitIgnored(),
     initialSlugs: ctx.allSlugs,
@@ -110,19 +132,181 @@ async function startServing(
     ignoreInitial: true,
   })
 
+  const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint
   watcher
-    .on("add", (fp) => rebuildFromEntrypoint(fp, "add", clientRefresh, buildData))
-    .on("change", (fp) => rebuildFromEntrypoint(fp, "change", clientRefresh, buildData))
-    .on("unlink", (fp) => rebuildFromEntrypoint(fp, "delete", clientRefresh, buildData))
+    .on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData))
+    .on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData))
+    .on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData))
 
   return async () => {
     await watcher.close()
   }
 }
 
+async function partialRebuildFromEntrypoint(
+  filepath: string,
+  action: FileEvent,
+  clientRefresh: () => void,
+  buildData: BuildData, // note: this function mutates buildData
+) {
+  const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData
+  const { argv, cfg } = ctx
+
+  // don't do anything for gitignored files
+  if (ignored(filepath)) {
+    return
+  }
+
+  const buildStart = new Date().getTime()
+  buildData.lastBuildMs = buildStart
+  const release = await mut.acquire()
+  if (buildData.lastBuildMs > buildStart) {
+    release()
+    return
+  }
+
+  const perf = new PerfTimer()
+  console.log(chalk.yellow("Detected change, rebuilding..."))
+
+  // UPDATE DEP GRAPH
+  const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath
+
+  const staticResources = getStaticResourcesFromPlugins(ctx)
+  let processedFiles: ProcessedContent[] = []
+
+  switch (action) {
+    case "add":
+      // add to cache when new file is added
+      processedFiles = await parseMarkdown(ctx, [fp])
+      processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
+
+      // update the dep graph by asking all emitters whether they depend on this file
+      for (const emitter of cfg.plugins.emitters) {
+        const emitterGraph =
+          (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
+
+        // emmiter may not define a dependency graph. nothing to update if so
+        if (emitterGraph) {
+          dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp)
+        }
+      }
+      break
+    case "change":
+      // invalidate cache when file is changed
+      processedFiles = await parseMarkdown(ctx, [fp])
+      processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
+
+      // only content files can have added/removed dependencies because of transclusions
+      if (path.extname(fp) === ".md") {
+        for (const emitter of cfg.plugins.emitters) {
+          // get new dependencies from all emitters for this file
+          const emitterGraph =
+            (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
+
+          // emmiter may not define a dependency graph. nothing to update if so
+          if (emitterGraph) {
+            // merge the new dependencies into the dep graph
+            dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp)
+          }
+        }
+      }
+      break
+    case "delete":
+      toRemove.add(fp)
+      break
+  }
+
+  if (argv.verbose) {
+    console.log(`Updated dependency graphs in ${perf.timeSince()}`)
+  }
+
+  // EMIT
+  perf.addEvent("rebuild")
+  let emittedFiles = 0
+  const destinationsToDelete = new Set<FilePath>()
+
+  for (const emitter of cfg.plugins.emitters) {
+    const depGraph = dependencies[emitter.name]
+
+    // emitter hasn't defined a dependency graph. call it with all processed files
+    if (depGraph === null) {
+      if (argv.verbose) {
+        console.log(
+          `Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`,
+        )
+      }
+
+      const files = [...contentMap.values()].filter(
+        ([_node, vfile]) => !toRemove.has(vfile.data.filePath!),
+      )
+
+      const emittedFps = await emitter.emit(ctx, files, staticResources)
+
+      if (ctx.argv.verbose) {
+        for (const file of emittedFps) {
+          console.log(`[emit:${emitter.name}] ${file}`)
+        }
+      }
+
+      emittedFiles += emittedFps.length
+      continue
+    }
+
+    // only call the emitter if it uses this file
+    if (depGraph.hasNode(fp)) {
+      // re-emit using all files that are needed for the downstream of this file
+      // eg. for ContentIndex, the dep graph could be:
+      // a.md --> contentIndex.json
+      // b.md ------^
+      //
+      // if a.md changes, we need to re-emit contentIndex.json,
+      // and supply [a.md, b.md] to the emitter
+      const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[]
+
+      if (action === "delete" && upstreams.length === 1) {
+        // if there's only one upstream, the destination is solely dependent on this file
+        destinationsToDelete.add(upstreams[0])
+      }
+
+      const upstreamContent = upstreams
+        // filter out non-markdown files
+        .filter((file) => contentMap.has(file))
+        // if file was deleted, don't give it to the emitter
+        .filter((file) => !toRemove.has(file))
+        .map((file) => contentMap.get(file)!)
+
+      const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources)
+
+      if (ctx.argv.verbose) {
+        for (const file of emittedFps) {
+          console.log(`[emit:${emitter.name}] ${file}`)
+        }
+      }
+
+      emittedFiles += emittedFps.length
+    }
+  }
+
+  console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`)
+
+  // CLEANUP
+  // delete files that are solely dependent on this file
+  await rimraf([...destinationsToDelete])
+  for (const file of toRemove) {
+    // remove from cache
+    contentMap.delete(file)
+    // remove the node from dependency graphs
+    Object.values(dependencies).forEach((depGraph) => depGraph?.removeNode(file))
+  }
+
+  toRemove.clear()
+  release()
+  clientRefresh()
+}
+
 async function rebuildFromEntrypoint(
   fp: string,
-  action: "add" | "change" | "delete",
+  action: FileEvent,
   clientRefresh: () => void,
   buildData: BuildData, // note: this function mutates buildData
 ) {
diff --git a/quartz/cli/args.js b/quartz/cli/args.js
index 7ed5b07..123d0ac 100644
--- a/quartz/cli/args.js
+++ b/quartz/cli/args.js
@@ -71,6 +71,11 @@ export const BuildArgv = {
     default: false,
     describe: "run a local server to live-preview your Quartz",
   },
+  fastRebuild: {
+    boolean: true,
+    default: false,
+    describe: "[experimental] rebuild only the changed files",
+  },
   baseDir: {
     string: true,
     default: "",
diff --git a/quartz/depgraph.test.ts b/quartz/depgraph.test.ts
new file mode 100644
index 0000000..43eb402
--- /dev/null
+++ b/quartz/depgraph.test.ts
@@ -0,0 +1,96 @@
+import test, { describe } from "node:test"
+import DepGraph from "./depgraph"
+import assert from "node:assert"
+
+describe("DepGraph", () => {
+  test("getLeafNodes", () => {
+    const graph = new DepGraph<string>()
+    graph.addEdge("A", "B")
+    graph.addEdge("B", "C")
+    graph.addEdge("D", "C")
+    assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"]))
+    assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"]))
+    assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"]))
+    assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"]))
+  })
+
+  describe("getLeafNodeAncestors", () => {
+    test("gets correct ancestors in a graph without cycles", () => {
+      const graph = new DepGraph<string>()
+      graph.addEdge("A", "B")
+      graph.addEdge("B", "C")
+      graph.addEdge("D", "B")
+      assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"]))
+      assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"]))
+      assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"]))
+      assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"]))
+    })
+
+    test("gets correct ancestors in a graph with cycles", () => {
+      const graph = new DepGraph<string>()
+      graph.addEdge("A", "B")
+      graph.addEdge("B", "C")
+      graph.addEdge("C", "A")
+      graph.addEdge("C", "D")
+      assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"]))
+      assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"]))
+      assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"]))
+      assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"]))
+    })
+  })
+
+  describe("updateIncomingEdgesForNode", () => {
+    test("merges when node exists", () => {
+      // A.md -> B.md -> B.html
+      const graph = new DepGraph<string>()
+      graph.addEdge("A.md", "B.md")
+      graph.addEdge("B.md", "B.html")
+
+      // B.md is edited so it removes the A.md transclusion
+      // and adds C.md transclusion
+      // C.md -> B.md
+      const other = new DepGraph<string>()
+      other.addEdge("C.md", "B.md")
+      other.addEdge("B.md", "B.html")
+
+      // A.md -> B.md removed, C.md -> B.md added
+      // C.md -> B.md -> B.html
+      graph.updateIncomingEdgesForNode(other, "B.md")
+
+      const expected = {
+        nodes: ["A.md", "B.md", "B.html", "C.md"],
+        edges: [
+          ["B.md", "B.html"],
+          ["C.md", "B.md"],
+        ],
+      }
+
+      assert.deepStrictEqual(graph.export(), expected)
+    })
+
+    test("adds node if it does not exist", () => {
+      // A.md -> B.md
+      const graph = new DepGraph<string>()
+      graph.addEdge("A.md", "B.md")
+
+      // Add a new file C.md that transcludes B.md
+      // B.md -> C.md
+      const other = new DepGraph<string>()
+      other.addEdge("B.md", "C.md")
+
+      // B.md -> C.md added
+      // A.md -> B.md -> C.md
+      graph.updateIncomingEdgesForNode(other, "C.md")
+
+      const expected = {
+        nodes: ["A.md", "B.md", "C.md"],
+        edges: [
+          ["A.md", "B.md"],
+          ["B.md", "C.md"],
+        ],
+      }
+
+      assert.deepStrictEqual(graph.export(), expected)
+    })
+  })
+})
diff --git a/quartz/depgraph.ts b/quartz/depgraph.ts
new file mode 100644
index 0000000..1efad07
--- /dev/null
+++ b/quartz/depgraph.ts
@@ -0,0 +1,187 @@
+export default class DepGraph<T> {
+  // node: incoming and outgoing edges
+  _graph = new Map<T, { incoming: Set<T>; outgoing: Set<T> }>()
+
+  constructor() {
+    this._graph = new Map()
+  }
+
+  export(): Object {
+    return {
+      nodes: this.nodes,
+      edges: this.edges,
+    }
+  }
+
+  toString(): string {
+    return JSON.stringify(this.export(), null, 2)
+  }
+
+  // BASIC GRAPH OPERATIONS
+
+  get nodes(): T[] {
+    return Array.from(this._graph.keys())
+  }
+
+  get edges(): [T, T][] {
+    let edges: [T, T][] = []
+    this.forEachEdge((edge) => edges.push(edge))
+    return edges
+  }
+
+  hasNode(node: T): boolean {
+    return this._graph.has(node)
+  }
+
+  addNode(node: T): void {
+    if (!this._graph.has(node)) {
+      this._graph.set(node, { incoming: new Set(), outgoing: new Set() })
+    }
+  }
+
+  removeNode(node: T): void {
+    if (this._graph.has(node)) {
+      this._graph.delete(node)
+    }
+  }
+
+  hasEdge(from: T, to: T): boolean {
+    return Boolean(this._graph.get(from)?.outgoing.has(to))
+  }
+
+  addEdge(from: T, to: T): void {
+    this.addNode(from)
+    this.addNode(to)
+
+    this._graph.get(from)!.outgoing.add(to)
+    this._graph.get(to)!.incoming.add(from)
+  }
+
+  removeEdge(from: T, to: T): void {
+    if (this._graph.has(from) && this._graph.has(to)) {
+      this._graph.get(from)!.outgoing.delete(to)
+      this._graph.get(to)!.incoming.delete(from)
+    }
+  }
+
+  // returns -1 if node does not exist
+  outDegree(node: T): number {
+    return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1
+  }
+
+  // returns -1 if node does not exist
+  inDegree(node: T): number {
+    return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1
+  }
+
+  forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void {
+    this._graph.get(node)?.outgoing.forEach(callback)
+  }
+
+  forEachInNeighbor(node: T, callback: (neighbor: T) => void): void {
+    this._graph.get(node)?.incoming.forEach(callback)
+  }
+
+  forEachEdge(callback: (edge: [T, T]) => void): void {
+    for (const [source, { outgoing }] of this._graph.entries()) {
+      for (const target of outgoing) {
+        callback([source, target])
+      }
+    }
+  }
+
+  // DEPENDENCY ALGORITHMS
+
+  // For the node provided:
+  // If node does not exist, add it
+  // If an incoming edge was added in other, it is added in this graph
+  // If an incoming edge was deleted in other, it is deleted in this graph
+  updateIncomingEdgesForNode(other: DepGraph<T>, node: T): void {
+    this.addNode(node)
+
+    // Add edge if it is present in other
+    other.forEachInNeighbor(node, (neighbor) => {
+      this.addEdge(neighbor, node)
+    })
+
+    // For node provided, remove incoming edge if it is absent in other
+    this.forEachEdge(([source, target]) => {
+      if (target === node && !other.hasEdge(source, target)) {
+        this.removeEdge(source, target)
+      }
+    })
+  }
+
+  // Get all leaf nodes (i.e. destination paths) reachable from the node provided
+  // Eg. if the graph is A -> B -> C
+  //                     D ---^
+  // and the node is B, this function returns [C]
+  getLeafNodes(node: T): Set<T> {
+    let stack: T[] = [node]
+    let visited = new Set<T>()
+    let leafNodes = new Set<T>()
+
+    // DFS
+    while (stack.length > 0) {
+      let node = stack.pop()!
+
+      // If the node is already visited, skip it
+      if (visited.has(node)) {
+        continue
+      }
+      visited.add(node)
+
+      // Check if the node is a leaf node (i.e. destination path)
+      if (this.outDegree(node) === 0) {
+        leafNodes.add(node)
+      }
+
+      // Add all unvisited neighbors to the stack
+      this.forEachOutNeighbor(node, (neighbor) => {
+        if (!visited.has(neighbor)) {
+          stack.push(neighbor)
+        }
+      })
+    }
+
+    return leafNodes
+  }
+
+  // Get all ancestors of the leaf nodes reachable from the node provided
+  // Eg. if the graph is A -> B -> C
+  //                     D ---^
+  // and the node is B, this function returns [A, B, D]
+  getLeafNodeAncestors(node: T): Set<T> {
+    const leafNodes = this.getLeafNodes(node)
+    let visited = new Set<T>()
+    let upstreamNodes = new Set<T>()
+
+    // Backwards DFS for each leaf node
+    leafNodes.forEach((leafNode) => {
+      let stack: T[] = [leafNode]
+
+      while (stack.length > 0) {
+        let node = stack.pop()!
+
+        if (visited.has(node)) {
+          continue
+        }
+        visited.add(node)
+        // Add node if it's not a leaf node (i.e. destination path)
+        // Assumes destination file cannot depend on another destination file
+        if (this.outDegree(node) !== 0) {
+          upstreamNodes.add(node)
+        }
+
+        // Add all unvisited parents to the stack
+        this.forEachInNeighbor(node, (parentNode) => {
+          if (!visited.has(parentNode)) {
+            stack.push(parentNode)
+          }
+        })
+      }
+    })
+
+    return upstreamNodes
+  }
+}
diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx
index 079adbc..f9d7a86 100644
--- a/quartz/plugins/emitters/404.tsx
+++ b/quartz/plugins/emitters/404.tsx
@@ -9,6 +9,7 @@ import { NotFound } from "../../components"
 import { defaultProcessedContent } from "../vfile"
 import { write } from "./helpers"
 import { i18n } from "../../i18n"
+import DepGraph from "../../depgraph"
 
 export const NotFoundPage: QuartzEmitterPlugin = () => {
   const opts: FullPageLayout = {
@@ -27,6 +28,9 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
     getQuartzComponents() {
       return [Head, Body, pageBody, Footer]
     },
+    async getDependencyGraph(_ctx, _content, _resources) {
+      return new DepGraph<FilePath>()
+    },
     async emit(ctx, _content, resources): Promise<FilePath[]> {
       const cfg = ctx.cfg.configuration
       const slug = "404" as FullSlug
diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts
index d407629..fb25a44 100644
--- a/quartz/plugins/emitters/aliases.ts
+++ b/quartz/plugins/emitters/aliases.ts
@@ -2,12 +2,17 @@ import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from
 import { QuartzEmitterPlugin } from "../types"
 import path from "path"
 import { write } from "./helpers"
+import DepGraph from "../../depgraph"
 
 export const AliasRedirects: QuartzEmitterPlugin = () => ({
   name: "AliasRedirects",
   getQuartzComponents() {
     return []
   },
+  async getDependencyGraph(_ctx, _content, _resources) {
+    // TODO implement
+    return new DepGraph<FilePath>()
+  },
   async emit(ctx, content, _resources): Promise<FilePath[]> {
     const { argv } = ctx
     const fps: FilePath[] = []
diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts
index cc97b2e..379cd5b 100644
--- a/quartz/plugins/emitters/assets.ts
+++ b/quartz/plugins/emitters/assets.ts
@@ -3,6 +3,7 @@ import { QuartzEmitterPlugin } from "../types"
 import path from "path"
 import fs from "fs"
 import { glob } from "../../util/glob"
+import DepGraph from "../../depgraph"
 
 export const Assets: QuartzEmitterPlugin = () => {
   return {
@@ -10,6 +11,24 @@ export const Assets: QuartzEmitterPlugin = () => {
     getQuartzComponents() {
       return []
     },
+    async getDependencyGraph(ctx, _content, _resources) {
+      const { argv, cfg } = ctx
+      const graph = new DepGraph<FilePath>()
+
+      const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
+
+      for (const fp of fps) {
+        const ext = path.extname(fp)
+        const src = joinSegments(argv.directory, fp) as FilePath
+        const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath
+
+        const dest = joinSegments(argv.output, name) as FilePath
+
+        graph.addEdge(src, dest)
+      }
+
+      return graph
+    },
     async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
       // glob all non MD/MDX/HTML files in content folder and copy it over
       const assetsPath = argv.output
diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts
index 3e17fea..cbed2a8 100644
--- a/quartz/plugins/emitters/cname.ts
+++ b/quartz/plugins/emitters/cname.ts
@@ -2,6 +2,7 @@ import { FilePath, joinSegments } from "../../util/path"
 import { QuartzEmitterPlugin } from "../types"
 import fs from "fs"
 import chalk from "chalk"
+import DepGraph from "../../depgraph"
 
 export function extractDomainFromBaseUrl(baseUrl: string) {
   const url = new URL(`https://${baseUrl}`)
@@ -13,6 +14,9 @@ export const CNAME: QuartzEmitterPlugin = () => ({
   getQuartzComponents() {
     return []
   },
+  async getDependencyGraph(_ctx, _content, _resources) {
+    return new DepGraph<FilePath>()
+  },
   async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
     if (!cfg.configuration.baseUrl) {
       console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts
index 4033bdf..c3a60b2 100644
--- a/quartz/plugins/emitters/componentResources.ts
+++ b/quartz/plugins/emitters/componentResources.ts
@@ -14,6 +14,7 @@ import { googleFontHref, joinStyles } from "../../util/theme"
 import { Features, transform } from "lightningcss"
 import { transform as transpile } from "esbuild"
 import { write } from "./helpers"
+import DepGraph from "../../depgraph"
 
 type ComponentResources = {
   css: string[]
@@ -149,9 +150,10 @@ function addGlobalPageResources(
       loadTime: "afterDOMReady",
       contentType: "inline",
       script: `
-        const socket = new WebSocket('${wsUrl}')
-        socket.addEventListener('message', () => document.location.reload())
-      `,
+          const socket = new WebSocket('${wsUrl}')
+          // reload(true) ensures resources like images and scripts are fetched again in firefox
+          socket.addEventListener('message', () => document.location.reload(true))
+        `,
     })
   }
 }
@@ -171,6 +173,24 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
     getQuartzComponents() {
       return []
     },
+    async getDependencyGraph(ctx, content, _resources) {
+      // This emitter adds static resources to the `resources` parameter. One
+      // important resource this emitter adds is the code to start a websocket
+      // connection and listen to rebuild messages, which triggers a page reload.
+      // The resources parameter with the reload logic is later used by the
+      // ContentPage emitter while creating the final html page. In order for
+      // the reload logic to be included, and so for partial rebuilds to work,
+      // we need to run this emitter for all markdown files.
+      const graph = new DepGraph<FilePath>()
+
+      for (const [_tree, file] of content) {
+        const sourcePath = file.data.filePath!
+        const slug = file.data.slug!
+        graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath)
+      }
+
+      return graph
+    },
     async emit(ctx, _content, resources): Promise<FilePath[]> {
       const promises: Promise<FilePath>[] = []
       const cfg = ctx.cfg.configuration
diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts
index 1c86b71..c0fef86 100644
--- a/quartz/plugins/emitters/contentIndex.ts
+++ b/quartz/plugins/emitters/contentIndex.ts
@@ -7,6 +7,7 @@ import { QuartzEmitterPlugin } from "../types"
 import { toHtml } from "hast-util-to-html"
 import { write } from "./helpers"
 import { i18n } from "../../i18n"
+import DepGraph from "../../depgraph"
 
 export type ContentIndex = Map<FullSlug, ContentDetails>
 export type ContentDetails = {
@@ -92,6 +93,26 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
   opts = { ...defaultOptions, ...opts }
   return {
     name: "ContentIndex",
+    async getDependencyGraph(ctx, content, _resources) {
+      const graph = new DepGraph<FilePath>()
+
+      for (const [_tree, file] of content) {
+        const sourcePath = file.data.filePath!
+
+        graph.addEdge(
+          sourcePath,
+          joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath,
+        )
+        if (opts?.enableSiteMap) {
+          graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath)
+        }
+        if (opts?.enableRSS) {
+          graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath)
+        }
+      }
+
+      return graph
+    },
     async emit(ctx, content, _resources) {
       const cfg = ctx.cfg.configuration
       const emitted: FilePath[] = []
diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx
index b11890b..e531b36 100644
--- a/quartz/plugins/emitters/contentPage.tsx
+++ b/quartz/plugins/emitters/contentPage.tsx
@@ -4,11 +4,12 @@ import HeaderConstructor from "../../components/Header"
 import BodyConstructor from "../../components/Body"
 import { pageResources, renderPage } from "../../components/renderPage"
 import { FullPageLayout } from "../../cfg"
-import { FilePath, pathToRoot } from "../../util/path"
+import { FilePath, joinSegments, pathToRoot } from "../../util/path"
 import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
 import { Content } from "../../components"
 import chalk from "chalk"
 import { write } from "./helpers"
+import DepGraph from "../../depgraph"
 
 export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
   const opts: FullPageLayout = {
@@ -27,6 +28,18 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
     getQuartzComponents() {
       return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
     },
+    async getDependencyGraph(ctx, content, _resources) {
+      // TODO handle transclusions
+      const graph = new DepGraph<FilePath>()
+
+      for (const [_tree, file] of content) {
+        const sourcePath = file.data.filePath!
+        const slug = file.data.slug!
+        graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath)
+      }
+
+      return graph
+    },
     async emit(ctx, content, resources): Promise<FilePath[]> {
       const cfg = ctx.cfg.configuration
       const fps: FilePath[] = []
@@ -60,7 +73,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
         fps.push(fp)
       }
 
-      if (!containsIndex) {
+      if (!containsIndex && !ctx.argv.fastRebuild) {
         console.log(
           chalk.yellow(
             `\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. This may cause errors when deploying.`,
diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx
index 35c360a..7a62cda 100644
--- a/quartz/plugins/emitters/folderPage.tsx
+++ b/quartz/plugins/emitters/folderPage.tsx
@@ -19,6 +19,7 @@ import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.lay
 import { FolderContent } from "../../components"
 import { write } from "./helpers"
 import { i18n } from "../../i18n"
+import DepGraph from "../../depgraph"
 
 export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
   const opts: FullPageLayout = {
@@ -37,6 +38,13 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpt
     getQuartzComponents() {
       return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
     },
+    async getDependencyGraph(ctx, content, _resources) {
+      // Example graph:
+      // nested/file.md --> nested/file.html
+      //          \-------> nested/index.html
+      // TODO implement
+      return new DepGraph<FilePath>()
+    },
     async emit(ctx, content, resources): Promise<FilePath[]> {
       const fps: FilePath[] = []
       const allFiles = content.map((c) => c[1].data)
diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts
index 9f93d9b..c52c628 100644
--- a/quartz/plugins/emitters/static.ts
+++ b/quartz/plugins/emitters/static.ts
@@ -2,12 +2,27 @@ import { FilePath, QUARTZ, joinSegments } from "../../util/path"
 import { QuartzEmitterPlugin } from "../types"
 import fs from "fs"
 import { glob } from "../../util/glob"
+import DepGraph from "../../depgraph"
 
 export const Static: QuartzEmitterPlugin = () => ({
   name: "Static",
   getQuartzComponents() {
     return []
   },
+  async getDependencyGraph({ argv, cfg }, _content, _resources) {
+    const graph = new DepGraph<FilePath>()
+
+    const staticPath = joinSegments(QUARTZ, "static")
+    const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
+    for (const fp of fps) {
+      graph.addEdge(
+        joinSegments("static", fp) as FilePath,
+        joinSegments(argv.output, "static", fp) as FilePath,
+      )
+    }
+
+    return graph
+  },
   async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
     const staticPath = joinSegments(QUARTZ, "static")
     const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx
index 2411c68..332c758 100644
--- a/quartz/plugins/emitters/tagPage.tsx
+++ b/quartz/plugins/emitters/tagPage.tsx
@@ -16,6 +16,7 @@ import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.lay
 import { TagContent } from "../../components"
 import { write } from "./helpers"
 import { i18n } from "../../i18n"
+import DepGraph from "../../depgraph"
 
 export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
   const opts: FullPageLayout = {
@@ -34,6 +35,10 @@ export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts)
     getQuartzComponents() {
       return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
     },
+    async getDependencyGraph(ctx, _content, _resources) {
+      // TODO implement
+      return new DepGraph<FilePath>()
+    },
     async emit(ctx, content, resources): Promise<FilePath[]> {
       const fps: FilePath[] = []
       const allFiles = content.map((c) => c[1].data)
diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts
index a361bb9..a23f5d6 100644
--- a/quartz/plugins/types.ts
+++ b/quartz/plugins/types.ts
@@ -4,6 +4,7 @@ import { ProcessedContent } from "./vfile"
 import { QuartzComponent } from "../components/types"
 import { FilePath } from "../util/path"
 import { BuildCtx } from "../util/ctx"
+import DepGraph from "../depgraph"
 
 export interface PluginTypes {
   transformers: QuartzTransformerPluginInstance[]
@@ -38,4 +39,9 @@ export type QuartzEmitterPluginInstance = {
   name: string
   emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
   getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
+  getDependencyGraph?(
+    ctx: BuildCtx,
+    content: ProcessedContent[],
+    resources: StaticResources,
+  ): Promise<DepGraph<FilePath>>
 }
diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts
index 13e0bf8..e056114 100644
--- a/quartz/util/ctx.ts
+++ b/quartz/util/ctx.ts
@@ -6,6 +6,7 @@ export interface Argv {
   verbose: boolean
   output: string
   serve: boolean
+  fastRebuild: boolean
   port: number
   wsPort: number
   remoteDevHost?: string