LLN vs CLT: Why Averages Stabilize Before They Become Normal

Two theorems, two different promises

The Law of Large Numbers (LLN) and the Central Limit Theorem (CLT) are the two pillars of statistics, but they say different things:

LLN CLT
What it says Sample mean converges to the true mean Distribution of the (scaled) sample mean is approximately normal
Object \(\bar{X}_1, \bar{X}_2, \bar{X}_3, \dots\) — one sequence, growing \(n\) \(\bar{X}^{(1)}, \bar{X}^{(2)}, \bar{X}^{(3)}, \dots\) — many samples, fixed \(n\)
Key result \(\bar{X}_n \xrightarrow{p} \mu\) \(\sqrt{n}(\bar{X}_n - \mu) \xrightarrow{d} N(0, \sigma^2)\)
Interpretation Consistency Asymptotic normality
Promise You get the right answer You know how uncertain your estimate is
Requires Finite mean Finite variance

LLN says the mean settles down. CLT says where it settles follows a normal distribution.

Think of it this way: LLN tells you that if you flip a fair coin enough times, the fraction of heads approaches 0.5. CLT tells you that if you repeat the entire experiment many times, the distribution of those fractions is bell-shaped.

Want each theorem on its own first? See The Law of Large Numbers (what it says, weak vs. strong, and the running-average simulator) and The Central Limit Theorem. This page runs the two head-to-head.

The Oracle View. In these simulations, we know the true mean \(\mu\) — the horizontal line the running average converges to. In practice, you don’t know \(\mu\). You watch your estimate stabilize but never know exactly what it’s converging toward. That’s the gap between theory and practice: LLN guarantees convergence, but the target is invisible.

LLN vs CLT side by side

See both theorems at once. The left panel shows LLN — one running average converging to \(\mu\). The right panel shows CLT — the histogram of many sample means forming a normal distribution. Same data, different questions.

#| standalone: true
#| viewerHeight: 580

library(shiny)

ui <- fluidPage(
  tags$head(tags$style(HTML("
    .stats-box {
      background: #f0f4f8; border-radius: 6px; padding: 14px;
      margin-top: 12px; font-size: 14px; line-height: 1.9;
    }
    .stats-box b { color: #2c3e50; }
  "))),

  sidebarLayout(
    sidebarPanel(
      width = 3,

      selectInput("pop2", "Population:",
                  choices = c("Uniform(0, 1)",
                              "Exponential(1)",
                              "Bimodal",
                              "Bernoulli(0.3)")),

      sliderInput("n2", "Sample size per experiment:",
                  min = 5, max = 200, value = 30, step = 5),

      sliderInput("reps2", "Number of experiments:",
                  min = 200, max = 3000, value = 1000, step = 100),

      uiOutput("results2")
    ),

    mainPanel(
      width = 9,
      fluidRow(
        column(6, plotOutput("lln_plot", height = "430px")),
        column(6, plotOutput("clt_plot", height = "430px"))
      )
    )
  )
)

server <- function(input, output, session) {

  draw_pop <- function(n, pop) {
    switch(pop,
      "Uniform(0, 1)"  = runif(n),
      "Exponential(1)" = rexp(n, rate = 1),
      "Bimodal"        = {
        k <- rbinom(n, 1, 0.5)
        k * rnorm(n, -2, 0.6) + (1 - k) * rnorm(n, 2, 0.6)
      },
      "Bernoulli(0.3)" = rbinom(n, 1, 0.3)
    )
  }

  pop_mu <- function(pop) {
    switch(pop,
      "Uniform(0, 1)"  = 0.5,
      "Exponential(1)" = 1,
      "Bimodal"        = 0,
      "Bernoulli(0.3)" = 0.3
    )
  }

  pop_sigma <- function(pop) {
    switch(pop,
      "Uniform(0, 1)"  = sqrt(1 / 12),
      "Exponential(1)" = 1,
      "Bimodal"        = sqrt(0.6^2 + 4),
      "Bernoulli(0.3)" = sqrt(0.3 * 0.7)
    )
  }

  dat <- reactive({
    n    <- input$n2
    reps <- input$reps2
    pop  <- input$pop2
    mu   <- pop_mu(pop)
    sig  <- pop_sigma(pop)

    # One long draw for LLN
    long_draw <- draw_pop(n * 5, pop)
    running   <- cumsum(long_draw) / seq_along(long_draw)

    # Many experiments for CLT
    means <- replicate(reps, mean(draw_pop(n, pop)))

    list(running = running, means = means, mu = mu, sig = sig,
         n = n, reps = reps, pop = pop)
  })

  output$lln_plot <- renderPlot({
    d <- dat()
    par(mar = c(4.5, 4.5, 3, 1))

    plot(seq_along(d$running), d$running, type = "l",
         col = "#3498db", lwd = 1.5,
         xlab = "Number of observations",
         ylab = "Cumulative mean",
         main = "LLN: One running average")
    abline(h = d$mu, lty = 2, lwd = 2.5, col = "#e74c3c")

    legend("topright", bty = "n", cex = 0.85,
           legend = c("Running average",
                      paste0("True μ = ", d$mu)),
           col = c("#3498db", "#e74c3c"),
           lwd = c(1.5, 2.5), lty = c(1, 2))
  })

  output$clt_plot <- renderPlot({
    d <- dat()
    par(mar = c(4.5, 4.5, 3, 1))

    hist(d$means, breaks = 40, probability = TRUE,
         col = "#3498db30", border = "#3498db80",
         main = paste0("CLT: ", d$reps, " sample means (n=", d$n, ")"),
         xlab = "Sample mean", ylab = "Density")

    x_seq <- seq(min(d$means), max(d$means), length.out = 300)
    se <- d$sig / sqrt(d$n)
    lines(x_seq, dnorm(x_seq, mean = d$mu, sd = se),
          col = "#e74c3c", lwd = 2.5)

    abline(v = d$mu, lty = 2, lwd = 2, col = "#2c3e50")

    legend("topright", bty = "n", cex = 0.85,
           legend = c("Sampling distribution",
                      paste0("N(μ, σ/√n) overlay"),
                      paste0("True μ = ", d$mu)),
           col = c("#3498db80", "#e74c3c", "#2c3e50"),
           lwd = c(8, 2.5, 2), lty = c(1, 1, 2))
  })

  output$results2 <- renderUI({
    d <- dat()
    se <- d$sig / sqrt(d$n)

    tags$div(class = "stats-box",
      HTML(paste0(
        "<b>LLN (left):</b><br>",
        "Final avg: ", round(d$running[length(d$running)], 4), "<br>",
        "True μ: ", d$mu, "<br>",
        "<hr style='margin:8px 0'>",
        "<b>CLT (right):</b><br>",
        "Mean of means: ", round(mean(d$means), 4), "<br>",
        "SD of means: ", round(sd(d$means), 4), "<br>",
        "Theoretical SE: ", round(se, 4)
      ))
    )
  })
}

shinyApp(ui, server)

Things to try

  • Exponential, n = 5: the LLN panel shows the running average still wobbling, while the CLT histogram is visibly skewed. Neither theorem has fully “kicked in” yet — but they will at different rates.
  • Exponential, n = 100: the running average has settled (LLN), and the histogram is now bell-shaped (CLT). Both theorems have done their job.
  • Bimodal: the running average converges to 0 (the midpoint), even though no individual observation is near 0. LLN doesn’t care about the shape.
  • Multiple paths: all paths converge to the same \(\mu\), but they take different routes. The early wobble is sampling variability; the eventual convergence is LLN.

The bottom line

  • LLN = your estimate gets closer to the truth as \(n\) grows. It’s about accuracy of a single estimate.
  • CLT = the shape of the sampling distribution is approximately normal. It’s about the distribution of the estimator across repeated experiments.
  • You need LLN before CLT matters. If the mean hasn’t stabilized, knowing its distribution is normal doesn’t help much.

When does each theorem kick in?

LLN and CLT are both asymptotic results — they describe what happens as \(n \to \infty\). But in practice, they kick in at different speeds, and the speed depends on the population you’re sampling from.

LLN kicks in early. As soon as you have a few dozen observations, the sample mean is usually close to \(\mu\). The only requirement is a finite first moment (\(E[|X|] < \infty\)). Even heavily skewed distributions settle down quickly — the running average doesn’t care about the shape of the population, only that the mean exists.

CLT kicks in later, and the speed depends on the population shape. The CLT is about the distribution of \(\bar{X}_n\) converging to a bell curve — a stronger claim that requires more data. How much more depends on how non-normal the population is:

Population CLT kicks in at roughly… Why
Symmetric (Normal, Uniform) \(n \approx 5\text{--}10\) Already close to normal; little work for CLT to do
Moderately skewed (Exponential, \(\chi^2\)) \(n \approx 30\text{--}50\) Skewness needs to be averaged away
Heavily skewed (Bernoulli(0.05), Log-normal) \(n \approx 100\text{--}500\) Extreme skewness or heavy tails slow convergence
No finite variance (Cauchy) Never CLT requires \(\text{Var}(X) < \infty\); without it, the theorem doesn’t apply

The folklore rule “\(n \geq 30\) is enough” comes from CLT, not LLN. It’s roughly when the normal approximation becomes decent for moderately skewed populations. But it’s a rule of thumb, not a theorem — symmetric populations need far less, and heavily skewed populations need far more.

Why does skewness slow things down?

The CLT convergence rate is governed by the Berry-Esseen bound:

\[ \sup_z \left| P\!\left(\frac{\bar{X}_n - \mu}{\sigma / \sqrt{n}} \leq z\right) - \Phi(z) \right| \;\leq\; \frac{C \cdot \rho}{\sigma^3 \sqrt{n}} \]

where \(\rho = E[|X - \mu|^3]\) is the third absolute moment. The ratio \(\rho / \sigma^3\) measures how “non-normal” the population is — distributions with heavy tails or strong skewness have a large \(\rho / \sigma^3\), which means the bound shrinks more slowly with \(n\). That’s the mathematical reason skewed distributions need more data before CLT kicks in.

Watch CLT kick in at different speeds

Pick a population and see how the sampling distribution of \(\bar{X}_n\) (standardised) compares to the \(N(0, 1)\) curve at four sample sizes.

The KS (Kolmogorov–Smirnov) distance turns “how normal is it?” into one number. Lay the two cumulative curves on top of each other — the one built from the simulated standardised means and the theoretical \(N(0,1)\) — and read off the single largest vertical gap between them. That gap is the KS distance: \(0\) means identical to normal, and it grows as the two curves pull apart. Here it turns green once it drops below \(0.03\). Watch it shrink as \(n\) grows — that is the CLT arriving, measured in one number. (It is the same statistic as R’s ks.test.)

#| standalone: true
#| viewerHeight: 620

library(shiny)

ui <- fluidPage(
  tags$head(tags$style(HTML("
    .stats-box {
      background: #f0f4f8; border-radius: 6px; padding: 14px;
      margin-top: 12px; font-size: 14px; line-height: 1.9;
    }
    .stats-box b { color: #2c3e50; }
  "))),

  sidebarLayout(
    sidebarPanel(
      width = 3,

      selectInput("pop3", "Population:",
        choices = c(
          "Normal(0, 1)"   = "normal",
          "Uniform(0, 1)"  = "uniform",
          "Exponential(1)"  = "exp",
          "Chi-squared(3)"  = "chisq",
          "Bernoulli(0.1)"  = "bern",
          "Log-normal(0,1)" = "lognorm"
        ),
        selected = "exp"
      ),

      uiOutput("stats3")
    ),

    mainPanel(
      width = 9,
      plotOutput("grid_plot", height = "520px")
    )
  )
)

server <- function(input, output, session) {

  sim_data <- reactive({
    pop <- input$pop3
    B   <- 2000
    ns  <- c(5, 30, 100, 500)

    draw <- function(n) {
      switch(pop,
        normal  = rnorm(n),
        uniform = runif(n),
        exp     = rexp(n, 1),
        chisq   = rchisq(n, df = 3),
        bern    = rbinom(n, 1, 0.1),
        lognorm = rlnorm(n, 0, 1))
    }

    mu <- switch(pop,
      normal = 0, uniform = 0.5, exp = 1,
      chisq = 3, bern = 0.1, lognorm = exp(0.5))

    sig <- switch(pop,
      normal = 1, uniform = sqrt(1/12), exp = 1,
      chisq = sqrt(6), bern = sqrt(0.1 * 0.9),
      lognorm = sqrt((exp(1) - 1) * exp(1)))

    results <- lapply(ns, function(n) {
      means <- replicate(B, mean(draw(n)))
      list(means = means, n = n)
    })

    list(results = results, mu = mu, sig = sig,
         pop = pop, ns = ns)
  })

  output$grid_plot <- renderPlot({
    d <- sim_data()

    par(mfrow = c(2, 2), mar = c(4, 4, 3.5, 1), oma = c(0, 0, 2, 0))

    pop_label <- switch(d$pop,
      normal = "Normal(0, 1)", uniform = "Uniform(0, 1)",
      exp = "Exponential(1)", chisq = "χ²(3)",
      bern = "Bernoulli(0.1)", lognorm = "Log-normal(0, 1)")

    for (i in seq_along(d$results)) {
      r  <- d$results[[i]]
      se <- d$sig / sqrt(r$n)
      z  <- (r$means - d$mu) / se

      hist(z, breaks = 40, freq = FALSE, col = "#dfe6e9",
           border = "#b2bec3", xlim = c(-4, 4),
           main = paste0("n = ", r$n),
           xlab = "Standardised mean", ylab = "Density",
           cex.main = 1.3)

      xseq <- seq(-4, 4, length.out = 300)
      lines(xseq, dnorm(xseq), lwd = 2.5, col = "#e74c3c", lty = 2)

      ks <- suppressWarnings(ks.test(z, "pnorm")$statistic)
      legend("topright", bty = "n", cex = 0.85,
             legend = paste0("KS = ", sprintf("%.3f", ks)),
             text.col = if (ks < 0.03) "#27ae60" else "#e74c3c")
    }

    mtext(paste0("CLT convergence: ", pop_label),
          side = 3, outer = TRUE, cex = 1.2, font = 2)
  })

  output$stats3 <- renderUI({
    d <- sim_data()

    ks_vals <- sapply(d$results, function(r) {
      se <- d$sig / sqrt(r$n)
      z  <- (r$means - d$mu) / se
      suppressWarnings(ks.test(z, "pnorm")$statistic)
    })

    tags$div(class = "stats-box",
      HTML(paste0(
        "<b>KS distance from N(0,1):</b><br>",
        paste(paste0("n = ", d$ns, ": <b>",
              sprintf("%.3f", ks_vals), "</b>"),
              collapse = "<br>"),
        "<br><hr style='margin:8px 0'>",
        "<small>KS &rarr; 0 means the<br>",
        "sampling distribution is<br>",
        "converging to normal.</small>"
      ))
    )
  })
}

shinyApp(ui, server)

What to try:

  • Normal: CLT is perfect even at \(n = 5\) (KS \(\approx 0\)) — the population is already normal, so averages are exactly normal at every \(n\).
  • Exponential: at \(n = 5\) the histogram is visibly right-skewed. By \(n = 30\) it’s close. By \(n = 100\) it’s indistinguishable from the bell curve.
  • Bernoulli(0.1): at \(n = 5\) the sample mean can only be 0, 0.2, 0.4, … — you can see the discreteness. The CLT needs \(n = 100\)+ to smooth this out.
  • Log-normal: the slowest convergence of the bunch. Heavy right tail means the CLT needs \(n = 300\)+ before the KS distance gets small. This is why the “\(n \geq 30\) rule” can be dangerously optimistic.